From 04bf12642546df9b29b5b7094b486e57f65c6396 Mon Sep 17 00:00:00 2001 From: Andrew Onyshchuk Date: Fri, 1 Sep 2023 13:28:53 -0700 Subject: [PATCH 001/640] Update aiotractive to 0.5.6 (#99477) --- homeassistant/components/tractive/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tractive/manifest.json b/homeassistant/components/tractive/manifest.json index 9e448d1fd26e1a..75ddf065bd7178 100644 --- a/homeassistant/components/tractive/manifest.json +++ b/homeassistant/components/tractive/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "cloud_push", "loggers": ["aiotractive"], - "requirements": ["aiotractive==0.5.5"] + "requirements": ["aiotractive==0.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index c84a21f3d8c478..b6b801e0e691df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -360,7 +360,7 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi aiounifi==58 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ef67aca2937915..e71ca77a0597f3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -335,7 +335,7 @@ aioswitcher==3.3.0 aiosyncthing==0.5.1 # homeassistant.components.tractive -aiotractive==0.5.5 +aiotractive==0.5.6 # homeassistant.components.unifi aiounifi==58 From 5a8fc43212c12978b7943a2b6c060ed8b587febd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 16:40:53 -0400 Subject: [PATCH 002/640] Refactor MQTT discovery to avoid creating closure if hash already in discovery_pending_discovered (#99458) --- homeassistant/components/mqtt/discovery.py | 34 ++++++++++++---------- 1 file changed, 18 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 0002a1866a4d5f..b05e57280f313e 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -275,32 +275,34 @@ def async_process_discovery_payload( _LOGGER.debug("Process discovery payload %s", payload) discovery_hash = (component, discovery_id) - if discovery_hash in mqtt_data.discovery_already_discovered or payload: + + already_discovered = discovery_hash in mqtt_data.discovery_already_discovered + if ( + already_discovered or payload + ) and discovery_hash not in mqtt_data.discovery_pending_discovered: + discovery_pending_discovered = mqtt_data.discovery_pending_discovered @callback def discovery_done(_: Any) -> None: - pending = mqtt_data.discovery_pending_discovered[discovery_hash][ - "pending" - ] + pending = discovery_pending_discovered[discovery_hash]["pending"] _LOGGER.debug("Pending discovery for %s: %s", discovery_hash, pending) if not pending: - mqtt_data.discovery_pending_discovered[discovery_hash]["unsub"]() - mqtt_data.discovery_pending_discovered.pop(discovery_hash) + discovery_pending_discovered[discovery_hash]["unsub"]() + discovery_pending_discovered.pop(discovery_hash) else: payload = pending.pop() async_process_discovery_payload(component, discovery_id, payload) - if discovery_hash not in mqtt_data.discovery_pending_discovered: - mqtt_data.discovery_pending_discovered[discovery_hash] = { - "unsub": async_dispatcher_connect( - hass, - MQTT_DISCOVERY_DONE.format(discovery_hash), - discovery_done, - ), - "pending": deque([]), - } + discovery_pending_discovered[discovery_hash] = { + "unsub": async_dispatcher_connect( + hass, + MQTT_DISCOVERY_DONE.format(discovery_hash), + discovery_done, + ), + "pending": deque([]), + } - if discovery_hash in mqtt_data.discovery_already_discovered: + if already_discovered: # Dispatch update message = f"Component has already been discovered: {component} {discovery_id}, sending update" async_log_discovery_origin_info(message, payload) From 7c87b38a23a50a038e4ad66818e9b51ff745ce86 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 1 Sep 2023 16:41:34 -0400 Subject: [PATCH 003/640] Reduce overhead to process and publish MQTT messages (#99457) --- homeassistant/components/mqtt/client.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 62f1f55401d0b8..733645c4788b41 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -110,7 +110,7 @@ def publish( encoding: str | None = DEFAULT_ENCODING, ) -> None: """Publish message to a MQTT topic.""" - hass.add_job(async_publish, hass, topic, payload, qos, retain, encoding) + hass.create_task(async_publish(hass, topic, payload, qos, retain, encoding)) async def async_publish( @@ -376,6 +376,7 @@ def __init__( ) -> None: """Initialize Home Assistant MQTT client.""" self.hass = hass + self.loop = hass.loop self.config_entry = config_entry self.conf = conf @@ -806,7 +807,7 @@ def _mqtt_on_message( self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage ) -> None: """Message received callback.""" - self.hass.add_job(self._mqtt_handle_message, msg) + self.loop.call_soon_threadsafe(self._mqtt_handle_message, msg) @lru_cache(None) # pylint: disable=method-cache-max-size-none def _matching_subscriptions(self, topic: str) -> list[Subscription]: From e465a4f8209a664bee244bc55ecce30ddf07cc4e Mon Sep 17 00:00:00 2001 From: Jc2k Date: Fri, 1 Sep 2023 23:33:19 +0100 Subject: [PATCH 004/640] Update bluetooth-data-tools to 1.11.0 (#99485) --- 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/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 59a87f4dfbbe4e..54c8a52e24b551 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -18,7 +18,7 @@ "bleak-retry-connector==3.1.1", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", - "bluetooth-data-tools==1.9.1", + "bluetooth-data-tools==1.11.0", "dbus-fast==1.94.1" ] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index 5a4220464e77a4..bfb33c7b7d066d 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -17,7 +17,7 @@ "requirements": [ "async_interrupt==1.1.1", "aioesphomeapi==16.0.3", - "bluetooth-data-tools==1.9.1", + "bluetooth-data-tools==1.11.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 0c77e0e2ef5684..798a80147de526 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.9.1", "ld2410-ble==0.1.1"] + "requirements": ["bluetooth-data-tools==1.11.0", "ld2410-ble==0.1.1"] } diff --git a/homeassistant/components/led_ble/manifest.json b/homeassistant/components/led_ble/manifest.json index 36e3b7355ff41f..da5b4b0a4ee30d 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.9.1", "led-ble==1.0.0"] + "requirements": ["bluetooth-data-tools==1.11.0", "led-ble==1.0.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 19169de83f671c..286bc927d452ba 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -12,7 +12,7 @@ bleak-retry-connector==3.1.1 bleak==0.20.2 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 diff --git a/requirements_all.txt b/requirements_all.txt index b6b801e0e691df..f7355ea948313e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -549,7 +549,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e71ca77a0597f3..3e3c6aab866e52 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -460,7 +460,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble -bluetooth-data-tools==1.9.1 +bluetooth-data-tools==1.11.0 # homeassistant.components.bond bond-async==0.2.1 From b681dc06e0a633a85b5d9dd8cf680e2a3ac8ee68 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 2 Sep 2023 09:47:59 +0200 Subject: [PATCH 005/640] Fix default language in Workday (#99463) Workday fix default language --- homeassistant/components/workday/binary_sensor.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/workday/binary_sensor.py b/homeassistant/components/workday/binary_sensor.py index 6b6dfbffa5d94a..ad18c8863d68c0 100644 --- a/homeassistant/components/workday/binary_sensor.py +++ b/homeassistant/components/workday/binary_sensor.py @@ -129,7 +129,13 @@ async def async_setup_entry( workdays: list[str] = entry.options[CONF_WORKDAYS] year: int = (dt_util.now() + timedelta(days=days_offset)).year - obj_holidays: HolidayBase = country_holidays(country, subdiv=province, years=year) + cls: HolidayBase = country_holidays(country, subdiv=province, years=year) + obj_holidays: HolidayBase = country_holidays( + country, + subdiv=province, + years=year, + language=cls.default_language, + ) # Add custom holidays try: From 5fd14eade570eeaf004609d9ea996b1a9ef4a9f8 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Sat, 2 Sep 2023 01:20:36 -0700 Subject: [PATCH 006/640] Handle timestamp sensors in Prometheus integration (#98001) --- homeassistant/components/prometheus/__init__.py | 16 +++++++++++++++- tests/components/prometheus/test_init.py | 16 ++++++++++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index e5d7f6cb060c3a..adc5225b28602a 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -19,6 +19,7 @@ from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION from homeassistant.components.http import HomeAssistantView from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY +from homeassistant.components.sensor import SensorDeviceClass from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_DEVICE_CLASS, @@ -44,6 +45,7 @@ from homeassistant.helpers.entity_registry import EVENT_ENTITY_REGISTRY_UPDATED from homeassistant.helpers.entity_values import EntityValues from homeassistant.helpers.typing import ConfigType +from homeassistant.util.dt import as_timestamp from homeassistant.util.unit_conversion import TemperatureConverter _LOGGER = logging.getLogger(__name__) @@ -147,6 +149,7 @@ def __init__( self._sensor_metric_handlers = [ self._sensor_override_component_metric, self._sensor_override_metric, + self._sensor_timestamp_metric, self._sensor_attribute_metric, self._sensor_default_metric, self._sensor_fallback_metric, @@ -292,7 +295,10 @@ def _sanitize_metric_name(metric: str) -> str: def state_as_number(state): """Return a state casted to a float.""" try: - value = state_helper.state_as_number(state) + if state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP: + value = as_timestamp(state.state) + else: + value = state_helper.state_as_number(state) except ValueError: _LOGGER.debug("Could not convert %s to float", state) value = 0 @@ -576,6 +582,14 @@ def _sensor_attribute_metric(state, unit): return f"sensor_{metric}_{unit}" return None + @staticmethod + def _sensor_timestamp_metric(state, unit): + """Get metric for timestamp sensors, which have no unit of measurement attribute.""" + metric = state.attributes.get(ATTR_DEVICE_CLASS) + if metric == SensorDeviceClass.TIMESTAMP: + return f"sensor_{metric}_seconds" + return None + def _sensor_override_metric(self, state, unit): """Get metric from override in configuration.""" if self._override_metric: diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 446666c4a6aea5..82a205eb259bd7 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -232,6 +232,12 @@ async def test_sensor_device_class(client, sensor_entities) -> None: 'friendly_name="Radio Energy"} 14.0' in body ) + assert ( + 'sensor_timestamp_seconds{domain="sensor",' + 'entity="sensor.timestamp",' + 'friendly_name="Timestamp"} 1.691445808136036e+09' in body + ) + @pytest.mark.parametrize("namespace", [""]) async def test_input_number(client, input_number_entities) -> None: @@ -1049,6 +1055,16 @@ async def sensor_fixture( set_state_with_entry(hass, sensor_11, 50) data["sensor_11"] = sensor_11 + sensor_12 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_12", + original_device_class=SensorDeviceClass.TIMESTAMP, + suggested_object_id="Timestamp", + original_name="Timestamp", + ) + set_state_with_entry(hass, sensor_12, "2023-08-07T15:03:28.136036-0700") + data["sensor_12"] = sensor_12 await hass.async_block_till_done() return data From 1e46ecbb4823e4c0fcb5c60413fd6ce7b917675d Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Sat, 2 Sep 2023 10:55:12 +0200 Subject: [PATCH 007/640] Fix translation bug Renson sensors (#99461) * Fix translation bug * Revert "Fix translation bug" This reverts commit 84b5e90dac1e75a4c9f6d890865ac42044858682. * Fixed translation of Renson sensor --- homeassistant/components/renson/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index c8a355a0f7c86a..661ab82f37397d 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -266,6 +266,8 @@ class RensonSensorEntityDescription( class RensonSensor(RensonEntity, SensorEntity): """Get a sensor data from the Renson API and store it in the state of the class.""" + _attr_has_entity_name = True + def __init__( self, description: RensonSensorEntityDescription, From 3d1efaa4ad7f7a63dfc614a8512b2679cb1f201d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Sep 2023 11:10:57 +0200 Subject: [PATCH 008/640] Freeze time for MQTT sensor expire tests (#99496) --- tests/components/mqtt/test_binary_sensor.py | 106 ++++++++++---------- tests/components/mqtt/test_sensor.py | 66 ++++++------ 2 files changed, 90 insertions(+), 82 deletions(-) diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 28bf5f558cbcfa..91a4833b1fcfd5 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -146,57 +147,64 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "ON") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value was set correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_ON + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_ON - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + # 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 + # Value was updated correctly. + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_OFF + # Value is not yet expired + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_OFF - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("binary_sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("binary_sensor.test") + assert state.state == STATE_UNAVAILABLE async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( hass: HomeAssistant, mqtt_mock_entry: MqttMockHAClientGenerator, caplog: pytest.LogCaptureFixture, + freezer: FrozenDateTimeFactory, ) -> None: """Test that binary_sensor with expire_after set behaves correctly on discovery and discovery update.""" await mqtt_mock_entry() @@ -212,31 +220,28 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( # Set time and publish config message to create binary_sensor via discovery with 4 s expiry realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + freezer.move_to(now) + async_fire_time_changed(hass, now) + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is not available state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Publish state message - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message(hass, "test-topic", "ON") - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "test-topic", "ON") + await hass.async_block_till_done() # Test that binary_sensor has correct state state = hass.states.get("binary_sensor.test") assert state.state == STATE_ON # Advance +3 seconds - now = now + timedelta(seconds=3) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # binary_sensor is not yet expired state = hass.states.get("binary_sensor.test") @@ -255,21 +260,18 @@ async def test_expiration_on_discovery_and_discovery_update_of_binary_sensor( assert state.state == STATE_ON # Add +2 seconds - now = now + timedelta(seconds=2) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() # Test that binary_sensor has expired state = hass.states.get("binary_sensor.test") assert state.state == STATE_UNAVAILABLE # Resend config message to update discovery - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): - async_fire_mqtt_message( - hass, "homeassistant/binary_sensor/bla/config", config_msg - ) - await hass.async_block_till_done() + async_fire_mqtt_message(hass, "homeassistant/binary_sensor/bla/config", config_msg) + await hass.async_block_till_done() # Test that binary_sensor is still expired state = hass.states.get("binary_sensor.test") diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index 043c8d539b6f80..d9c92b315b3d62 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import MagicMock, patch +from freezegun import freeze_time from freezegun.api import FrozenDateTimeFactory import pytest @@ -360,51 +361,56 @@ async def expires_helper(hass: HomeAssistant) -> None: """Run the basic expiry code.""" realnow = dt_util.utcnow() now = datetime(realnow.year + 1, 1, 1, 1, tzinfo=dt_util.UTC) - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + with freeze_time(now) as freezer: + freezer.move_to(now) async_fire_time_changed(hass, now) async_fire_mqtt_message(hass, "test-topic", "100") await hass.async_block_till_done() - # Value was set correctly. - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value was set correctly. + state = hass.states.get("sensor.test") + assert state.state == "100" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "100" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "100" - # Next message resets timer - with patch(("homeassistant.helpers.event.dt_util.utcnow"), return_value=now): + # Next message resets timer + 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" + # Value was updated correctly. + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +3s - now = now + timedelta(seconds=3) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +3s + now += timedelta(seconds=3) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is not yet expired - state = hass.states.get("sensor.test") - assert state.state == "101" + # Value is not yet expired + state = hass.states.get("sensor.test") + assert state.state == "101" - # Time jump +2s - now = now + timedelta(seconds=2) - async_fire_time_changed(hass, now) - await hass.async_block_till_done() + # Time jump +2s + now += timedelta(seconds=2) + freezer.move_to(now) + async_fire_time_changed(hass, now) + await hass.async_block_till_done() - # Value is expired now - state = hass.states.get("sensor.test") - assert state.state == STATE_UNAVAILABLE + # Value is expired now + state = hass.states.get("sensor.test") + assert state.state == STATE_UNAVAILABLE @pytest.mark.parametrize( From f48e8623da8443f72e617db5d22f919a28a8c063 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 2 Sep 2023 11:55:19 +0200 Subject: [PATCH 009/640] Use shorthand attributes in Hunterdouglas powerview (#99386) --- .../hunterdouglas_powerview/scene.py | 19 ++++--------------- 1 file changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/hunterdouglas_powerview/scene.py b/homeassistant/components/hunterdouglas_powerview/scene.py index ba1221a25accdd..0c09917d35b52c 100644 --- a/homeassistant/components/hunterdouglas_powerview/scene.py +++ b/homeassistant/components/hunterdouglas_powerview/scene.py @@ -35,25 +35,14 @@ async def async_setup_entry( class PowerViewScene(HDEntity, Scene): """Representation of a Powerview scene.""" + _attr_icon = "mdi:blinds" + def __init__(self, coordinator, device_info, room_name, scene): """Initialize the scene.""" super().__init__(coordinator, device_info, room_name, scene.id) self._scene = scene - - @property - def name(self): - """Return the name of the scene.""" - return self._scene.name - - @property - def extra_state_attributes(self): - """Return the state attributes.""" - return {STATE_ATTRIBUTE_ROOM_NAME: self._room_name} - - @property - def icon(self): - """Icon to use in the frontend.""" - return "mdi:blinds" + self._attr_name = scene.name + self._attr_extra_state_attributes = {STATE_ATTRIBUTE_ROOM_NAME: room_name} async def async_activate(self, **kwargs: Any) -> None: """Activate scene. Try to get entities into requested state.""" From 4d3b978398818f4fe7a2094cb54f83c20a57ef18 Mon Sep 17 00:00:00 2001 From: Paarth Shah Date: Sat, 2 Sep 2023 06:02:55 -0700 Subject: [PATCH 010/640] Change matrix component to use matrix-nio instead of matrix_client (#72797) --- .coveragerc | 3 +- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/matrix/__init__.py | 537 ++++++++++-------- homeassistant/components/matrix/manifest.json | 4 +- homeassistant/components/matrix/notify.py | 13 +- mypy.ini | 10 + requirements_all.txt | 3 +- requirements_test.txt | 1 + requirements_test_all.txt | 4 + tests/components/matrix/__init__.py | 1 + tests/components/matrix/conftest.py | 248 ++++++++ tests/components/matrix/test_join_rooms.py | 22 + tests/components/matrix/test_login.py | 118 ++++ tests/components/matrix/test_matrix_bot.py | 88 +++ tests/components/matrix/test_send_message.py | 71 +++ 16 files changed, 881 insertions(+), 245 deletions(-) create mode 100644 tests/components/matrix/__init__.py create mode 100644 tests/components/matrix/conftest.py create mode 100644 tests/components/matrix/test_join_rooms.py create mode 100644 tests/components/matrix/test_login.py create mode 100644 tests/components/matrix/test_matrix_bot.py create mode 100644 tests/components/matrix/test_send_message.py diff --git a/.coveragerc b/.coveragerc index bf3dd5f4a00cf1..d5a491a330f3ac 100644 --- a/.coveragerc +++ b/.coveragerc @@ -705,7 +705,8 @@ omit = homeassistant/components/mailgun/notify.py homeassistant/components/map/* homeassistant/components/mastodon/notify.py - homeassistant/components/matrix/* + homeassistant/components/matrix/__init__.py + homeassistant/components/matrix/notify.py homeassistant/components/matter/__init__.py homeassistant/components/meater/__init__.py homeassistant/components/meater/sensor.py diff --git a/.strict-typing b/.strict-typing index e8bca0a1abd181..3059c42f33fab6 100644 --- a/.strict-typing +++ b/.strict-typing @@ -213,6 +213,7 @@ homeassistant.components.lookin.* homeassistant.components.luftdaten.* homeassistant.components.mailbox.* homeassistant.components.mastodon.* +homeassistant.components.matrix.* homeassistant.components.matter.* homeassistant.components.media_extractor.* homeassistant.components.media_player.* diff --git a/CODEOWNERS b/CODEOWNERS index 65a36205518470..bf6fdaf9fc5713 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -723,6 +723,8 @@ build.json @home-assistant/supervisor /homeassistant/components/lyric/ @timmo001 /tests/components/lyric/ @timmo001 /homeassistant/components/mastodon/ @fabaff +/homeassistant/components/matrix/ @PaarthShah +/tests/components/matrix/ @PaarthShah /homeassistant/components/matter/ @home-assistant/matter /tests/components/matter/ @home-assistant/matter /homeassistant/components/mazda/ @bdr99 diff --git a/homeassistant/components/matrix/__init__.py b/homeassistant/components/matrix/__init__.py index febafc367f1be2..cf7bcce7b3c7e6 100644 --- a/homeassistant/components/matrix/__init__.py +++ b/homeassistant/components/matrix/__init__.py @@ -1,10 +1,28 @@ """The Matrix bot component.""" -from functools import partial +from __future__ import annotations + +import asyncio import logging import mimetypes import os - -from matrix_client.client import MatrixClient, MatrixRequestError +import re +from typing import NewType, TypedDict + +import aiofiles.os +from nio import AsyncClient, Event, MatrixRoom +from nio.events.room_events import RoomMessageText +from nio.responses import ( + ErrorResponse, + JoinError, + JoinResponse, + LoginError, + Response, + UploadError, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image import voluptuous as vol from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET @@ -16,8 +34,8 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) -from homeassistant.core import HomeAssistant, ServiceCall -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import Event as HassEvent, HomeAssistant, ServiceCall +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError import homeassistant.helpers.config_validation as cv from homeassistant.helpers.json import save_json from homeassistant.helpers.typing import ConfigType @@ -35,23 +53,37 @@ CONF_WORD = "word" CONF_EXPRESSION = "expression" +EVENT_MATRIX_COMMAND = "matrix_command" + DEFAULT_CONTENT_TYPE = "application/octet-stream" MESSAGE_FORMATS = [FORMAT_HTML, FORMAT_TEXT] DEFAULT_MESSAGE_FORMAT = FORMAT_TEXT -EVENT_MATRIX_COMMAND = "matrix_command" - ATTR_FORMAT = "format" # optional message format ATTR_IMAGES = "images" # optional images +WordCommand = NewType("WordCommand", str) +ExpressionCommand = NewType("ExpressionCommand", re.Pattern) +RoomID = NewType("RoomID", str) + + +class ConfigCommand(TypedDict, total=False): + """Corresponds to a single COMMAND_SCHEMA.""" + + name: str # CONF_NAME + rooms: list[RoomID] | None # CONF_ROOMS + word: WordCommand | None # CONF_WORD + expression: ExpressionCommand | None # CONF_EXPRESSION + + COMMAND_SCHEMA = vol.All( vol.Schema( { 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, default=[]): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_ROOMS): vol.All(cv.ensure_list, [cv.string]), } ), cv.has_at_least_one_key(CONF_WORD, CONF_EXPRESSION), @@ -75,7 +107,6 @@ extra=vol.ALLOW_EXTRA, ) - SERVICE_SCHEMA_SEND_MESSAGE = vol.Schema( { vol.Required(ATTR_MESSAGE): cv.string, @@ -90,30 +121,26 @@ ) -def setup(hass: HomeAssistant, config: ConfigType) -> bool: +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the Matrix bot component.""" config = config[DOMAIN] - try: - bot = MatrixBot( - hass, - os.path.join(hass.config.path(), SESSION_FILE), - config[CONF_HOMESERVER], - config[CONF_VERIFY_SSL], - config[CONF_USERNAME], - config[CONF_PASSWORD], - config[CONF_ROOMS], - config[CONF_COMMANDS], - ) - hass.data[DOMAIN] = bot - except MatrixRequestError as exception: - _LOGGER.error("Matrix failed to log in: %s", str(exception)) - return False + matrix_bot = MatrixBot( + hass, + os.path.join(hass.config.path(), SESSION_FILE), + config[CONF_HOMESERVER], + config[CONF_VERIFY_SSL], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config[CONF_ROOMS], + config[CONF_COMMANDS], + ) + hass.data[DOMAIN] = matrix_bot - hass.services.register( + hass.services.async_register( DOMAIN, SERVICE_SEND_MESSAGE, - bot.handle_send_message, + matrix_bot.handle_send_message, schema=SERVICE_SCHEMA_SEND_MESSAGE, ) @@ -123,164 +150,141 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: class MatrixBot: """The Matrix Bot.""" + _client: AsyncClient + def __init__( self, - hass, - config_file, - homeserver, - verify_ssl, - username, - password, - listening_rooms, - commands, - ): + hass: HomeAssistant, + config_file: str, + homeserver: str, + verify_ssl: bool, + username: str, + password: str, + listening_rooms: list[RoomID], + commands: list[ConfigCommand], + ) -> None: """Set up the client.""" self.hass = hass self._session_filepath = config_file - self._auth_tokens = self._get_auth_tokens() + self._access_tokens: JsonObjectType = {} self._homeserver = homeserver self._verify_tls = verify_ssl self._mx_id = username self._password = password - self._listening_rooms = listening_rooms - - # We have to fetch the aliases for every room to make sure we don't - # join it twice by accident. However, fetching aliases is costly, - # so we only do it once per room. - self._aliases_fetched_for = set() - - # Word commands are stored dict-of-dict: First dict indexes by room ID - # / alias, second dict indexes by the word - self._word_commands = {} + self._client = AsyncClient( + homeserver=self._homeserver, user=self._mx_id, ssl=self._verify_tls + ) - # Regular expression commands are stored as a list of commands per - # room, i.e., a dict-of-list - self._expression_commands = {} + self._listening_rooms = listening_rooms - for command in commands: - if not command.get(CONF_ROOMS): - command[CONF_ROOMS] = listening_rooms - - if command.get(CONF_WORD): - for room_id in command[CONF_ROOMS]: - if room_id not in self._word_commands: - self._word_commands[room_id] = {} - self._word_commands[room_id][command[CONF_WORD]] = command - else: - for room_id in command[CONF_ROOMS]: - if room_id not in self._expression_commands: - self._expression_commands[room_id] = [] - self._expression_commands[room_id].append(command) + self._word_commands: dict[RoomID, dict[WordCommand, ConfigCommand]] = {} + self._expression_commands: dict[RoomID, list[ConfigCommand]] = {} + self._load_commands(commands) - # Log in. This raises a MatrixRequestError if login is unsuccessful - self._client = self._login() + async def stop_client(event: HassEvent) -> None: + """Run once when Home Assistant stops.""" + if self._client is not None: + await self._client.close() - def handle_matrix_exception(exception): - """Handle exceptions raised inside the Matrix SDK.""" - _LOGGER.error("Matrix exception:\n %s", str(exception)) + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) - self._client.start_listener_thread(exception_handler=handle_matrix_exception) + async def handle_startup(event: HassEvent) -> None: + """Run once when Home Assistant finished startup.""" + self._access_tokens = await self._get_auth_tokens() + await self._login() + await self._join_rooms() + # Sync once so that we don't respond to past events. + await self._client.sync(timeout=30_000) - def stop_client(_): - """Run once when Home Assistant stops.""" - self._client.stop_listener_thread() + self._client.add_event_callback(self._handle_room_message, RoomMessageText) - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_client) + await self._client.sync_forever( + timeout=30_000, + loop_sleep_time=1_000, + ) # milliseconds. - # Joining rooms potentially does a lot of I/O, so we defer it - def handle_startup(_): - """Run once when Home Assistant finished startup.""" - self._join_rooms() + self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, handle_startup) - self.hass.bus.listen_once(EVENT_HOMEASSISTANT_START, handle_startup) + 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_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set. + if (word_command := command.get(CONF_WORD)) is not None: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._word_commands.setdefault(room_id, {}) + self._word_commands[room_id][word_command] = command # type: ignore[index] + else: + for room_id in command[CONF_ROOMS]: # type: ignore[literal-required] + self._expression_commands.setdefault(room_id, []) + self._expression_commands[room_id].append(command) - def _handle_room_message(self, room_id, room, event): + async def _handle_room_message(self, room: MatrixRoom, message: Event) -> None: """Handle a message sent to a Matrix room.""" - if event["content"]["msgtype"] != "m.text": + # Corresponds to message type 'm.text' and NOT other RoomMessage subtypes, like 'm.notice' and 'm.emote'. + if not isinstance(message, RoomMessageText): return - - if event["sender"] == self._mx_id: + # Don't respond to our own messages. + if message.sender == self._mx_id: return + _LOGGER.debug("Handling message: %s", message.body) - _LOGGER.debug("Handling message: %s", event["content"]["body"]) + room_id = RoomID(room.room_id) - if event["content"]["body"][0] == "!": - # Could trigger a single-word command - pieces = event["content"]["body"].split(" ") - cmd = pieces[0][1:] + if message.body.startswith("!"): + # Could trigger a single-word command. + pieces = message.body.split() + word = WordCommand(pieces[0].lstrip("!")) - command = self._word_commands.get(room_id, {}).get(cmd) - if command: - event_data = { + if command := self._word_commands.get(room_id, {}).get(word): + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": pieces[1:], } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) - # After single-word commands, check all regex commands in the room + # After single-word commands, check all regex commands in the room. for command in self._expression_commands.get(room_id, []): - match = command[CONF_EXPRESSION].match(event["content"]["body"]) + match: re.Match = command[CONF_EXPRESSION].match(message.body) # type: ignore[literal-required] if not match: continue - event_data = { + message_data = { "command": command[CONF_NAME], - "sender": event["sender"], + "sender": message.sender, "room": room_id, "args": match.groupdict(), } - self.hass.bus.fire(EVENT_MATRIX_COMMAND, event_data) - - def _join_or_get_room(self, room_id_or_alias): - """Join a room or get it, if we are already in the room. - - We can't just always call join_room(), since that seems to crash - the client if we're already in the room. - """ - rooms = self._client.get_rooms() - if room_id_or_alias in rooms: - _LOGGER.debug("Already in room %s", room_id_or_alias) - return rooms[room_id_or_alias] - - for room in rooms.values(): - if room.room_id not in self._aliases_fetched_for: - room.update_aliases() - self._aliases_fetched_for.add(room.room_id) - - if ( - room_id_or_alias in room.aliases - or room_id_or_alias == room.canonical_alias - ): - _LOGGER.debug( - "Already in room %s (known as %s)", room.room_id, room_id_or_alias - ) - return room - - room = self._client.join_room(room_id_or_alias) - _LOGGER.info("Joined room %s (known as %s)", room.room_id, room_id_or_alias) - return room + self.hass.bus.async_fire(EVENT_MATRIX_COMMAND, message_data) + + async def _join_room(self, room_id_or_alias: str) -> None: + """Join a room or do nothing if already joined.""" + join_response = await self._client.join(room_id_or_alias) + + if isinstance(join_response, JoinResponse): + _LOGGER.debug("Joined or already in room '%s'", room_id_or_alias) + elif isinstance(join_response, JoinError): + _LOGGER.error( + "Could not join room '%s': %s", + room_id_or_alias, + join_response, + ) - def _join_rooms(self): + async def _join_rooms(self) -> None: """Join the Matrix rooms that we listen for commands in.""" - for room_id in self._listening_rooms: - try: - room = self._join_or_get_room(room_id) - room.add_listener( - partial(self._handle_room_message, room_id), "m.room.message" - ) - - except MatrixRequestError as ex: - _LOGGER.error("Could not join room %s: %s", room_id, ex) - - def _get_auth_tokens(self) -> JsonObjectType: - """Read sorted authentication tokens from disk. - - Returns the auth_tokens dictionary. - """ + rooms = [ + self.hass.async_create_task(self._join_room(room_id)) + for room_id in self._listening_rooms + ] + await asyncio.wait(rooms) + + async def _get_auth_tokens(self) -> JsonObjectType: + """Read sorted authentication tokens from disk.""" try: return load_json_object(self._session_filepath) except HomeAssistantError as ex: @@ -291,116 +295,179 @@ def _get_auth_tokens(self) -> JsonObjectType: ) return {} - def _store_auth_token(self, token): + async def _store_auth_token(self, token: str) -> None: """Store authentication token to session and persistent storage.""" - self._auth_tokens[self._mx_id] = token + self._access_tokens[self._mx_id] = token - save_json(self._session_filepath, self._auth_tokens) + await self.hass.async_add_executor_job( + save_json, self._session_filepath, self._access_tokens, True # private=True + ) - def _login(self): - """Login to the Matrix homeserver and return the client instance.""" - # Attempt to generate a valid client using either of the two possible - # login methods: - client = None + async def _login(self) -> None: + """Log in to the Matrix homeserver. - # If we have an authentication token - if self._mx_id in self._auth_tokens: - try: - client = self._login_by_token() - _LOGGER.debug("Logged in using stored token") + Attempts to use the stored access token. + If that fails, then tries using the password. + If that also fails, raises LocalProtocolError. + """ - except MatrixRequestError as ex: + # If we have an access token + if (token := self._access_tokens.get(self._mx_id)) is not None: + _LOGGER.debug("Restoring login from stored access token") + self._client.restore_login( + user_id=self._client.user_id, + device_id=self._client.device_id, + access_token=token, + ) + response = await self._client.whoami() + if isinstance(response, WhoamiError): _LOGGER.warning( - "Login by token failed, falling back to password: %d, %s", - ex.code, - ex.content, + "Restoring login from access token failed: %s, %s", + response.status_code, + response.message, + ) + self._client.access_token = ( + "" # Force a soft-logout if the homeserver didn't. + ) + elif isinstance(response, WhoamiResponse): + _LOGGER.debug( + "Successfully restored login from access token: user_id '%s', device_id '%s'", + response.user_id, + response.device_id, ) - # If we still don't have a client try password - if not client: - try: - client = self._login_by_password() - _LOGGER.debug("Logged in using password") - - except MatrixRequestError as ex: - _LOGGER.error( - "Login failed, both token and username/password invalid: %d, %s", - ex.code, - ex.content, + # If the token login did not succeed + if not self._client.logged_in: + response = await self._client.login(password=self._password) + _LOGGER.debug("Logging in using password") + + if isinstance(response, LoginError): + _LOGGER.warning( + "Login by password failed: %s, %s", + response.status_code, + response.message, ) - # Re-raise the error so _setup can catch it - raise - - return client - - def _login_by_token(self): - """Login using authentication token and return the client.""" - return MatrixClient( - base_url=self._homeserver, - token=self._auth_tokens[self._mx_id], - user_id=self._mx_id, - valid_cert_check=self._verify_tls, - ) - def _login_by_password(self): - """Login using password authentication and return the client.""" - _client = MatrixClient( - base_url=self._homeserver, valid_cert_check=self._verify_tls + if not self._client.logged_in: + raise ConfigEntryAuthFailed( + "Login failed, both token and username/password are invalid" + ) + + await self._store_auth_token(self._client.access_token) + + async def _handle_room_send( + self, target_room: RoomID, message_type: str, content: dict + ) -> None: + """Wrap _client.room_send and handle ErrorResponses.""" + response: Response = await self._client.room_send( + room_id=target_room, + message_type=message_type, + content=content, ) + if isinstance(response, ErrorResponse): + _LOGGER.error( + "Unable to deliver message to room '%s': %s", + target_room, + response, + ) + else: + _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 + ) -> None: + """Wrap _handle_room_send for multiple target_rooms.""" + _tasks = [] + for target_room in target_rooms: + _tasks.append( + self.hass.async_create_task( + self._handle_room_send( + target_room=target_room, + message_type=message_type, + content=content, + ) + ) + ) + await asyncio.wait(_tasks) - _client.login_with_password(self._mx_id, self._password) + async def _send_image(self, image_path: str, target_rooms: list[RoomID]) -> 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 + ) + if not _is_allowed_path: + _LOGGER.error("Path not allowed: %s", image_path) + return - self._store_auth_token(_client.token) + # Get required image metadata. + image = await self.hass.async_add_executor_job(Image.open, image_path) + (width, height) = image.size + mime_type = mimetypes.guess_type(image_path)[0] + file_stat = await aiofiles.os.stat(image_path) + + _LOGGER.debug("Uploading file from path, %s", image_path) + async with aiofiles.open(image_path, "r+b") as image_file: + response, _ = await self._client.upload( + image_file, + content_type=mime_type, + filename=os.path.basename(image_path), + filesize=file_stat.st_size, + ) + if isinstance(response, UploadError): + _LOGGER.error("Unable to upload image to the homeserver: %s", response) + return + if isinstance(response, UploadResponse): + _LOGGER.debug("Successfully uploaded image to the homeserver") + else: + _LOGGER.error( + "Unknown response received when uploading image to homeserver: %s", + response, + ) + return - return _client + content = { + "body": os.path.basename(image_path), + "info": { + "size": file_stat.st_size, + "mimetype": mime_type, + "w": width, + "h": height, + }, + "msgtype": "m.image", + "url": response.content_uri, + } - def _send_image(self, img, target_rooms): - _LOGGER.debug("Uploading file from path, %s", img) + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) - if not self.hass.config.is_allowed_path(img): - _LOGGER.error("Path not allowed: %s", img) - return - with open(img, "rb") as upfile: - imgfile = upfile.read() - content_type = mimetypes.guess_type(img)[0] - mxc = self._client.upload(imgfile, content_type) - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - room.send_image(mxc, img, mimetype=content_type) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) + async def _send_message( + self, message: str, target_rooms: list[RoomID], data: dict | None + ) -> None: + """Send a message to the Matrix server.""" + content = {"msgtype": "m.text", "body": message} + if data is not None and data.get(ATTR_FORMAT) == FORMAT_HTML: + content |= {"format": "org.matrix.custom.html", "formatted_body": message} - def _send_message(self, message, data, target_rooms): - """Send the message to the Matrix server.""" - for target_room in target_rooms: - try: - room = self._join_or_get_room(target_room) - if message is not None: - if data.get(ATTR_FORMAT) == FORMAT_HTML: - _LOGGER.debug(room.send_html(message)) - else: - _LOGGER.debug(room.send_text(message)) - except MatrixRequestError as ex: - _LOGGER.error( - "Unable to deliver message to room '%s': %d, %s", - target_room, - ex.code, - ex.content, - ) - if ATTR_IMAGES in data: - for img in data.get(ATTR_IMAGES, []): - self._send_image(img, target_rooms) + await self._handle_multi_room_send( + target_rooms=target_rooms, message_type="m.room.message", content=content + ) - def handle_send_message(self, service: ServiceCall) -> None: + if ( + data is not None + and (image_paths := data.get(ATTR_IMAGES, [])) + and len(target_rooms) > 0 + ): + image_tasks = [ + self.hass.async_create_task(self._send_image(image_path, target_rooms)) + for image_path in image_paths + ] + await asyncio.wait(image_tasks) + + async def handle_send_message(self, service: ServiceCall) -> None: """Handle the send_message service.""" - self._send_message( - service.data.get(ATTR_MESSAGE), - service.data.get(ATTR_DATA), + await self._send_message( + service.data[ATTR_MESSAGE], service.data[ATTR_TARGET], + service.data.get(ATTR_DATA), ) diff --git a/homeassistant/components/matrix/manifest.json b/homeassistant/components/matrix/manifest.json index 4bded80a71164e..74bb97d10fca95 100644 --- a/homeassistant/components/matrix/manifest.json +++ b/homeassistant/components/matrix/manifest.json @@ -1,9 +1,9 @@ { "domain": "matrix", "name": "Matrix", - "codeowners": [], + "codeowners": ["@PaarthShah"], "documentation": "https://www.home-assistant.io/integrations/matrix", "iot_class": "cloud_push", "loggers": ["matrix_client"], - "requirements": ["matrix-client==0.4.0"] + "requirements": ["matrix-nio==0.21.2", "Pillow==10.0.0"] } diff --git a/homeassistant/components/matrix/notify.py b/homeassistant/components/matrix/notify.py index 3c90e9afbc04c9..c71f91eb582ab1 100644 --- a/homeassistant/components/matrix/notify.py +++ b/homeassistant/components/matrix/notify.py @@ -1,6 +1,8 @@ """Support for Matrix notifications.""" from __future__ import annotations +from typing import Any + import voluptuous as vol from homeassistant.components.notify import ( @@ -14,6 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import RoomID from .const import DOMAIN, SERVICE_SEND_MESSAGE CONF_DEFAULT_ROOM = "default_room" @@ -33,16 +36,14 @@ def get_service( class MatrixNotificationService(BaseNotificationService): """Send notifications to a Matrix room.""" - def __init__(self, default_room): + def __init__(self, default_room: RoomID) -> None: """Set up the Matrix notification service.""" self._default_room = default_room - def send_message(self, message="", **kwargs): + def send_message(self, message: str = "", **kwargs: Any) -> None: """Send the message to the Matrix server.""" - target_rooms = kwargs.get(ATTR_TARGET) or [self._default_room] + target_rooms: list[RoomID] = kwargs.get(ATTR_TARGET) or [self._default_room] service_data = {ATTR_TARGET: target_rooms, ATTR_MESSAGE: message} if (data := kwargs.get(ATTR_DATA)) is not None: service_data[ATTR_DATA] = data - return self.hass.services.call( - DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data - ) + self.hass.services.call(DOMAIN, SERVICE_SEND_MESSAGE, service_data=service_data) diff --git a/mypy.ini b/mypy.ini index 82cce328c6ae5d..9802c26c3c6461 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1892,6 +1892,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.matrix.*] +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.matter.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index f7355ea948313e..4c5497ae98c981 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -37,6 +37,7 @@ Mastodon.py==1.5.1 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -1177,7 +1178,7 @@ lxml==4.9.3 mac-vendor-lookup==0.1.12 # homeassistant.components.matrix -matrix-client==0.4.0 +matrix-nio==0.21.2 # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/requirements_test.txt b/requirements_test.txt index a2533d0ef2b629..89db04a5db83cc 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -33,6 +33,7 @@ requests_mock==1.11.0 respx==0.20.2 syrupy==4.2.1 tqdm==4.66.1 +types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 types-croniter==1.0.6 types-backports==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3e3c6aab866e52..18e4f21914e482 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -33,6 +33,7 @@ HATasmota==0.7.0 # homeassistant.components.doods # homeassistant.components.generic # homeassistant.components.image_upload +# homeassistant.components.matrix # homeassistant.components.proxy # homeassistant.components.qrcode # homeassistant.components.seven_segments @@ -899,6 +900,9 @@ lxml==4.9.3 # homeassistant.components.nmap_tracker mac-vendor-lookup==0.1.12 +# homeassistant.components.matrix +matrix-nio==0.21.2 + # homeassistant.components.maxcube maxcube-api==0.4.3 diff --git a/tests/components/matrix/__init__.py b/tests/components/matrix/__init__.py new file mode 100644 index 00000000000000..a520f7e7c23d0e --- /dev/null +++ b/tests/components/matrix/__init__.py @@ -0,0 +1 @@ +"""Tests for the Matrix component.""" diff --git a/tests/components/matrix/conftest.py b/tests/components/matrix/conftest.py new file mode 100644 index 00000000000000..d0970b96019b17 --- /dev/null +++ b/tests/components/matrix/conftest.py @@ -0,0 +1,248 @@ +"""Define fixtures available for all tests.""" +from __future__ import annotations + +import re +import tempfile +from unittest.mock import patch + +from nio import ( + AsyncClient, + ErrorResponse, + JoinError, + JoinResponse, + LocalProtocolError, + LoginError, + LoginResponse, + Response, + UploadResponse, + WhoamiError, + WhoamiResponse, +) +from PIL import Image +import pytest + +from homeassistant.components.matrix import ( + CONF_COMMANDS, + CONF_EXPRESSION, + CONF_HOMESERVER, + CONF_ROOMS, + CONF_WORD, + EVENT_MATRIX_COMMAND, + MatrixBot, + RoomID, +) +from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN +from homeassistant.components.matrix.notify import CONF_DEFAULT_ROOM +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PLATFORM, + CONF_USERNAME, + CONF_VERIFY_SSL, +) +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import async_capture_events + +TEST_NOTIFIER_NAME = "matrix_notify" + +TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com" +TEST_JOINABLE_ROOMS = ["!RoomIdString:example.com", "#RoomAliasString:example.com"] +TEST_BAD_ROOM = "!UninvitedRoom:example.com" +TEST_MXID = "@user:example.com" +TEST_DEVICE_ID = "FAKEID" +TEST_PASSWORD = "password" +TEST_TOKEN = "access_token" + +NIO_IMPORT_PREFIX = "homeassistant.components.matrix.nio." + + +class _MockAsyncClient(AsyncClient): + """Mock class to simulate MatrixBot._client's I/O methods.""" + + async def close(self): + return None + + async def join(self, room_id: RoomID): + if room_id in TEST_JOINABLE_ROOMS: + return JoinResponse(room_id=room_id) + else: + return JoinError(message="Not allowed to join this room.") + + async def login(self, *args, **kwargs): + if kwargs.get("password") == TEST_PASSWORD or kwargs.get("token") == TEST_TOKEN: + self.access_token = TEST_TOKEN + return LoginResponse( + access_token=TEST_TOKEN, + device_id="test_device", + user_id=TEST_MXID, + ) + else: + self.access_token = "" + return LoginError(message="LoginError", status_code="status_code") + + async def logout(self, *args, **kwargs): + self.access_token = "" + + async def whoami(self): + if self.access_token == TEST_TOKEN: + self.user_id = TEST_MXID + self.device_id = TEST_DEVICE_ID + return WhoamiResponse( + user_id=TEST_MXID, device_id=TEST_DEVICE_ID, is_guest=False + ) + else: + self.access_token = "" + return WhoamiError( + message="Invalid access token passed.", status_code="M_UNKNOWN_TOKEN" + ) + + 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: + return ErrorResponse(message="Cannot send a message in this room.") + + async def sync(self, *args, **kwargs): + return None + + async def sync_forever(self, *args, **kwargs): + return None + + async def upload(self, *args, **kwargs): + return UploadResponse(content_uri="mxc://example.com/randomgibberish"), None + + +MOCK_CONFIG_DATA = { + MATRIX_DOMAIN: { + CONF_HOMESERVER: "https://matrix.example.com", + CONF_USERNAME: TEST_MXID, + CONF_PASSWORD: TEST_PASSWORD, + CONF_VERIFY_SSL: True, + CONF_ROOMS: TEST_JOINABLE_ROOMS, + CONF_COMMANDS: [ + { + CONF_WORD: "WordTrigger", + CONF_NAME: "WordTriggerEventName", + }, + { + CONF_EXPRESSION: "My name is (?P.*)", + CONF_NAME: "ExpressionTriggerEventName", + }, + ], + }, + NOTIFY_DOMAIN: { + CONF_NAME: TEST_NOTIFIER_NAME, + CONF_PLATFORM: MATRIX_DOMAIN, + CONF_DEFAULT_ROOM: TEST_DEFAULT_ROOM, + }, +} + +MOCK_WORD_COMMANDS = { + "!RoomIdString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, + "#RoomAliasString:example.com": { + "WordTrigger": { + "word": "WordTrigger", + "name": "WordTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + }, +} + +MOCK_EXPRESSION_COMMANDS = { + "!RoomIdString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], + "#RoomAliasString:example.com": [ + { + "expression": re.compile("My name is (?P.*)"), + "name": "ExpressionTriggerEventName", + "rooms": ["!RoomIdString:example.com", "#RoomAliasString:example.com"], + } + ], +} + + +@pytest.fixture +def mock_client(): + """Return mocked AsyncClient.""" + with patch("homeassistant.components.matrix.AsyncClient", _MockAsyncClient) as mock: + yield mock + + +@pytest.fixture +def mock_save_json(): + """Prevent saving test access_tokens.""" + with patch("homeassistant.components.matrix.save_json") as mock: + yield mock + + +@pytest.fixture +def mock_load_json(): + """Mock loading access_tokens from a file.""" + with patch( + "homeassistant.components.matrix.load_json_object", + return_value={TEST_MXID: TEST_TOKEN}, + ) as mock: + yield mock + + +@pytest.fixture +def mock_allowed_path(): + """Allow using NamedTemporaryFile for mock image.""" + with patch("homeassistant.core.Config.is_allowed_path", return_value=True) as mock: + yield mock + + +@pytest.fixture +async def matrix_bot( + hass: HomeAssistant, mock_client, mock_save_json, mock_allowed_path +) -> MatrixBot: + """Set up Matrix and Notify component. + + The resulting MatrixBot will have a mocked _client. + """ + + assert await async_setup_component(hass, MATRIX_DOMAIN, MOCK_CONFIG_DATA) + assert await async_setup_component(hass, NOTIFY_DOMAIN, MOCK_CONFIG_DATA) + await hass.async_block_till_done() + assert isinstance(matrix_bot := hass.data[MATRIX_DOMAIN], MatrixBot) + + await hass.async_start() + + return matrix_bot + + +@pytest.fixture +def matrix_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, MATRIX_DOMAIN) + + +@pytest.fixture +def command_events(hass: HomeAssistant): + """Track event calls.""" + return async_capture_events(hass, EVENT_MATRIX_COMMAND) + + +@pytest.fixture +def image_path(tmp_path): + """Provide the Path to a mock image.""" + image = Image.new("RGBA", size=(50, 50), color=(256, 0, 0)) + image_file = tempfile.NamedTemporaryFile(dir=tmp_path) + image.save(image_file, "PNG") + return image_file diff --git a/tests/components/matrix/test_join_rooms.py b/tests/components/matrix/test_join_rooms.py new file mode 100644 index 00000000000000..54856b91ac3541 --- /dev/null +++ b/tests/components/matrix/test_join_rooms.py @@ -0,0 +1,22 @@ +"""Test MatrixBot._join.""" + +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): + """Test joining configured rooms.""" + + # Join configured rooms. + await matrix_bot._join_rooms() + 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] + await matrix_bot._join_rooms() + assert ( + f"Could not join room '{TEST_BAD_ROOM}': JoinError: Not allowed to join this room." + in caplog.messages + ) diff --git a/tests/components/matrix/test_login.py b/tests/components/matrix/test_login.py new file mode 100644 index 00000000000000..8112d98fc8c65f --- /dev/null +++ b/tests/components/matrix/test_login.py @@ -0,0 +1,118 @@ +"""Test MatrixBot._login.""" + +from pydantic.dataclasses import dataclass +import pytest + +from homeassistant.components.matrix import MatrixBot +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError + +from tests.components.matrix.conftest import ( + TEST_DEVICE_ID, + TEST_MXID, + TEST_PASSWORD, + TEST_TOKEN, +) + + +@dataclass +class LoginTestParameters: + """Dataclass of parameters representing the login parameters and expected result state.""" + + password: str + access_token: dict[str, str] + expected_login_state: bool + expected_caplog_messages: set[str] + expected_expection: type(Exception) | None = None + + +good_password_missing_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={}, + expected_login_state=True, + expected_caplog_messages={"Logging in using password"}, +) + +good_password_bad_token = LoginTestParameters( + password=TEST_PASSWORD, + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + }, +) + +bad_password_good_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: TEST_TOKEN}, + expected_login_state=True, + expected_caplog_messages={ + "Restoring login from stored access token", + f"Successfully restored login from access token: user_id '{TEST_MXID}', device_id '{TEST_DEVICE_ID}'", + }, +) + +bad_password_bad_access_token = LoginTestParameters( + password="WrongPassword", + access_token={TEST_MXID: "WrongToken"}, + expected_login_state=False, + expected_caplog_messages={ + "Restoring login from stored access token", + "Restoring login from access token failed: M_UNKNOWN_TOKEN, Invalid access token passed.", + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + +bad_password_missing_access_token = LoginTestParameters( + password="WrongPassword", + access_token={}, + expected_login_state=False, + expected_caplog_messages={ + "Logging in using password", + "Login by password failed: status_code, LoginError", + }, + expected_expection=ConfigEntryAuthFailed, +) + + +@pytest.mark.parametrize( + "params", + [ + good_password_missing_token, + good_password_bad_token, + bad_password_good_access_token, + bad_password_bad_access_token, + bad_password_missing_access_token, + ], +) +async def test_login( + matrix_bot: MatrixBot, caplog: pytest.LogCaptureFixture, params: LoginTestParameters +): + """Test logging in with the given parameters and expected state.""" + await matrix_bot._client.logout() + matrix_bot._password = params.password + matrix_bot._access_tokens = params.access_token + + if params.expected_expection: + with pytest.raises(params.expected_expection): + await matrix_bot._login() + else: + await matrix_bot._login() + assert matrix_bot._client.logged_in == params.expected_login_state + assert set(caplog.messages).issuperset(params.expected_caplog_messages) + + +async def test_get_auth_tokens(matrix_bot: MatrixBot, mock_load_json): + """Test loading access_tokens from a mocked file.""" + + # Test loading good tokens. + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {TEST_MXID: TEST_TOKEN} + + # Test miscellaneous error from hass. + mock_load_json.side_effect = HomeAssistantError() + loaded_tokens = await matrix_bot._get_auth_tokens() + assert loaded_tokens == {} diff --git a/tests/components/matrix/test_matrix_bot.py b/tests/components/matrix/test_matrix_bot.py new file mode 100644 index 00000000000000..0b150a629fe23a --- /dev/null +++ b/tests/components/matrix/test_matrix_bot.py @@ -0,0 +1,88 @@ +"""Configure and test MatrixBot.""" +from nio import MatrixRoom, RoomMessageText + +from homeassistant.components.matrix import ( + DOMAIN as MATRIX_DOMAIN, + SERVICE_SEND_MESSAGE, + MatrixBot, +) +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ( + MOCK_EXPRESSION_COMMANDS, + MOCK_WORD_COMMANDS, + TEST_JOINABLE_ROOMS, + TEST_NOTIFIER_NAME, +) + + +async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot): + """Test hass/MatrixBot state.""" + + services = hass.services.async_services() + + # Verify that the matrix service is registered + assert (matrix_service := services.get(MATRIX_DOMAIN)) + assert SERVICE_SEND_MESSAGE in matrix_service + + # Verify that the matrix notifier is registered + assert (notify_service := services.get(NOTIFY_DOMAIN)) + assert TEST_NOTIFIER_NAME in notify_service + + +async def test_commands(hass, matrix_bot: MatrixBot, command_events): + """Test that the configured commands were parsed correctly.""" + + 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 = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + + # Test single-word command. + word_command_message = RoomMessageText( + body="!WordTrigger arg1 arg2", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, word_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "WordTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": ["arg1", "arg2"], + } + + # Test expression command. + room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id) + expression_command_message = RoomMessageText( + body="My name is FakeName", + formatted_body=None, + format=None, + source={ + "event_id": "fake_event_id", + "sender": "@SomeUser:example.com", + "origin_server_ts": 123456789, + }, + ) + await matrix_bot._handle_room_message(room, expression_command_message) + await hass.async_block_till_done() + assert len(command_events) == 1 + event = command_events.pop() + assert event.data == { + "command": "ExpressionTriggerEventName", + "sender": "@SomeUser:example.com", + "room": room_id, + "args": {"name": "FakeName"}, + } diff --git a/tests/components/matrix/test_send_message.py b/tests/components/matrix/test_send_message.py new file mode 100644 index 00000000000000..34964f2b09132a --- /dev/null +++ b/tests/components/matrix/test_send_message.py @@ -0,0 +1,71 @@ +"""Test the send_message service.""" + +from homeassistant.components.matrix import ( + ATTR_FORMAT, + ATTR_IMAGES, + DOMAIN as MATRIX_DOMAIN, + MatrixBot, +) +from homeassistant.components.matrix.const import FORMAT_HTML, SERVICE_SEND_MESSAGE +from homeassistant.components.notify import ATTR_DATA, ATTR_MESSAGE, ATTR_TARGET +from homeassistant.core import HomeAssistant + +from tests.components.matrix.conftest import TEST_BAD_ROOM, TEST_JOINABLE_ROOMS + + +async def test_send_message( + hass: HomeAssistant, matrix_bot: MatrixBot, image_path, matrix_events, caplog +): + """Test the send_message service.""" + 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} + 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 + + # Send an HTML message without an attached image. + data = { + ATTR_MESSAGE: "Test message", + ATTR_TARGET: 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 + + # Send a message with an attached image. + data[ATTR_DATA] = {ATTR_IMAGES: [image_path.name]} + 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 + + +async def test_unsendable_message( + hass: HomeAssistant, matrix_bot: MatrixBot, matrix_events, caplog +): + """Test the send_message service with an invalid room.""" + assert len(matrix_events) == 0 + await matrix_bot._login() + + data = {ATTR_MESSAGE: "Test message", ATTR_TARGET: TEST_BAD_ROOM} + + await hass.services.async_call( + MATRIX_DOMAIN, SERVICE_SEND_MESSAGE, data, blocking=True + ) + + assert ( + f"Unable to deliver message to room '{TEST_BAD_ROOM}': ErrorResponse: Cannot send a message in this room." + in caplog.messages + ) From d88ee0dbe0253ec5e8895af1836bd543ec2e2c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Sat, 2 Sep 2023 15:08:49 +0200 Subject: [PATCH 011/640] Update Tibber library to 0.28.2 (#99115) --- homeassistant/components/tibber/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tibber/manifest.json b/homeassistant/components/tibber/manifest.json index c668430914fae7..1d8120a4321e38 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["tibber"], "quality_scale": "silver", - "requirements": ["pyTibber==0.28.0"] + "requirements": ["pyTibber==0.28.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4c5497ae98c981..d72b41f442e08a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1548,7 +1548,7 @@ pyRFXtrx==0.30.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 18e4f21914e482..3049cb33268d82 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1163,7 +1163,7 @@ pyElectra==1.2.0 pyRFXtrx==0.30.1 # homeassistant.components.tibber -pyTibber==0.28.0 +pyTibber==0.28.2 # homeassistant.components.dlink pyW215==0.7.0 From 26b1222faedd72be7c6f0f53209976ca7a34f978 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 2 Sep 2023 15:21:05 +0100 Subject: [PATCH 012/640] Support tracking private bluetooth devices (#99465) Co-authored-by: J. Nick Koston --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/private_ble_device/__init__.py | 19 ++ .../private_ble_device/config_flow.py | 60 +++++ .../components/private_ble_device/const.py | 2 + .../private_ble_device/coordinator.py | 236 ++++++++++++++++++ .../private_ble_device/device_tracker.py | 75 ++++++ .../components/private_ble_device/entity.py | 71 ++++++ .../private_ble_device/manifest.json | 10 + .../private_ble_device/strings.json | 20 ++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 1 + requirements_test_all.txt | 1 + .../components/private_ble_device/__init__.py | 78 ++++++ .../components/private_ble_device/conftest.py | 1 + .../private_ble_device/test_config_flow.py | 132 ++++++++++ .../private_ble_device/test_device_tracker.py | 183 ++++++++++++++ 19 files changed, 909 insertions(+) create mode 100644 homeassistant/components/private_ble_device/__init__.py create mode 100644 homeassistant/components/private_ble_device/config_flow.py create mode 100644 homeassistant/components/private_ble_device/const.py create mode 100644 homeassistant/components/private_ble_device/coordinator.py create mode 100644 homeassistant/components/private_ble_device/device_tracker.py create mode 100644 homeassistant/components/private_ble_device/entity.py create mode 100644 homeassistant/components/private_ble_device/manifest.json create mode 100644 homeassistant/components/private_ble_device/strings.json create mode 100644 tests/components/private_ble_device/__init__.py create mode 100644 tests/components/private_ble_device/conftest.py create mode 100644 tests/components/private_ble_device/test_config_flow.py create mode 100644 tests/components/private_ble_device/test_device_tracker.py diff --git a/.strict-typing b/.strict-typing index 3059c42f33fab6..2a6e9b04cbe7ac 100644 --- a/.strict-typing +++ b/.strict-typing @@ -255,6 +255,7 @@ homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.powerwall.* +homeassistant.components.private_ble_device.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* diff --git a/CODEOWNERS b/CODEOWNERS index bf6fdaf9fc5713..b937c2769fce18 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -951,6 +951,8 @@ build.json @home-assistant/supervisor /tests/components/poolsense/ @haemishkyd /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson /tests/components/powerwall/ @bdraco @jrester @daniel-simpson +/homeassistant/components/private_ble_device/ @Jc2k +/tests/components/private_ble_device/ @Jc2k /homeassistant/components/profiler/ @bdraco /tests/components/profiler/ @bdraco /homeassistant/components/progettihwsw/ @ardaseremet diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py new file mode 100644 index 00000000000000..c4666ccc02fa70 --- /dev/null +++ b/homeassistant/components/private_ble_device/__init__.py @@ -0,0 +1,19 @@ +"""Private BLE Device integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.DEVICE_TRACKER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up tracking of a private bluetooth device from a config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload entities for a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/private_ble_device/config_flow.py b/homeassistant/components/private_ble_device/config_flow.py new file mode 100644 index 00000000000000..5bf130a0396fff --- /dev/null +++ b/homeassistant/components/private_ble_device/config_flow.py @@ -0,0 +1,60 @@ +"""Config flow for the BLE Tracker.""" +from __future__ import annotations + +import base64 +import binascii +import logging + +import voluptuous as vol + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigFlow +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN +from .coordinator import async_last_service_info + +_LOGGER = logging.getLogger(__name__) + +CONF_IRK = "irk" + + +class BLEDeviceTrackerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for BLE Device Tracker.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, str] | None = None + ) -> FlowResult: + """Set up by user.""" + errors: dict[str, str] = {} + + if not bluetooth.async_scanner_count(self.hass, connectable=False): + return self.async_abort(reason="bluetooth_not_available") + + 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: + errors[CONF_IRK] = "irk_not_valid" + elif not (service_info := async_last_service_info(self.hass, irk_bytes)): + errors[CONF_IRK] = "irk_not_found" + else: + await self.async_set_unique_id(irk_bytes.hex()) + return self.async_create_entry( + title=service_info.name or "BLE Device Tracker", + data={CONF_IRK: irk_bytes.hex()}, + ) + + data_schema = vol.Schema({CONF_IRK: str}) + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) diff --git a/homeassistant/components/private_ble_device/const.py b/homeassistant/components/private_ble_device/const.py new file mode 100644 index 00000000000000..086fd06bfd5780 --- /dev/null +++ b/homeassistant/components/private_ble_device/const.py @@ -0,0 +1,2 @@ +"""Constants for Private BLE Device.""" +DOMAIN = "private_ble_device" diff --git a/homeassistant/components/private_ble_device/coordinator.py b/homeassistant/components/private_ble_device/coordinator.py new file mode 100644 index 00000000000000..863b283385175b --- /dev/null +++ b/homeassistant/components/private_ble_device/coordinator.py @@ -0,0 +1,236 @@ +"""Central manager for tracking devices with random but resolvable MAC addresses.""" +from __future__ import annotations + +from collections.abc import Callable +import logging +from typing import cast + +from bluetooth_data_tools import get_cipher_for_irk, resolve_private_address +from cryptography.hazmat.primitives.ciphers import Cipher + +from homeassistant.components import bluetooth +from homeassistant.components.bluetooth.match import BluetoothCallbackMatcher +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +UnavailableCallback = Callable[[bluetooth.BluetoothServiceInfoBleak], None] +Cancellable = Callable[[], None] + + +def async_last_service_info( + hass: HomeAssistant, irk: bytes +) -> bluetooth.BluetoothServiceInfoBleak | None: + """Find a BluetoothServiceInfoBleak for the irk. + + This iterates over all currently visible mac addresses and checks them against `irk`. + It returns the newest. + """ + + # This can't use existing data collected by the coordinator - its called when + # the coordinator doesn't know about the IRK, so doesn't optimise this lookup. + + cur: bluetooth.BluetoothServiceInfoBleak | None = None + cipher = get_cipher_for_irk(irk) + + for service_info in bluetooth.async_discovered_service_info(hass, False): + if resolve_private_address(cipher, service_info.address): + if not cur or cur.time < service_info.time: + cur = service_info + + return cur + + +class PrivateDevicesCoordinator: + """Monitor private bluetooth devices and correlate them with known IRK. + + This class should not be instanced directly - use `async_get_coordinator` to get an instance. + + There is a single shared coordinator for all instances of this integration. This is to avoid + unnecessary hashing (AES) operations as much as possible. + """ + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the manager.""" + self.hass = hass + + self._irks: dict[bytes, Cipher] = {} + self._unavailable_callbacks: dict[bytes, list[UnavailableCallback]] = {} + self._service_info_callbacks: dict[ + bytes, list[bluetooth.BluetoothCallback] + ] = {} + + self._mac_to_irk: dict[str, bytes] = {} + self._irk_to_mac: dict[bytes, str] = {} + + # These MAC addresses have been compared to the IRK list + # They are unknown, so we can ignore them. + self._ignored: dict[str, Cancellable] = {} + + self._unavailability_trackers: dict[bytes, Cancellable] = {} + self._listener_cancel: Cancellable | None = None + + def _async_ensure_started(self) -> None: + if not self._listener_cancel: + self._listener_cancel = bluetooth.async_register_callback( + self.hass, + self._async_track_service_info, + BluetoothCallbackMatcher(connectable=False), + bluetooth.BluetoothScanningMode.ACTIVE, + ) + + def _async_ensure_stopped(self) -> None: + if self._listener_cancel: + self._listener_cancel() + self._listener_cancel = None + + for cancel in self._ignored.values(): + cancel() + self._ignored.clear() + + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + # This should be called when the current MAC address associated with an IRK goes away. + if resolved := self._mac_to_irk.get(service_info.address): + if callbacks := self._unavailable_callbacks.get(resolved): + for cb in callbacks: + cb(service_info) + return + + def _async_irk_resolved_to_mac(self, irk: bytes, mac: str) -> None: + if previous_mac := self._irk_to_mac.get(irk): + self._mac_to_irk.pop(previous_mac, None) + + self._mac_to_irk[mac] = irk + self._irk_to_mac[irk] = mac + + # Stop ignoring this MAC + self._ignored.pop(mac, None) + + # Ignore availability events for the previous address + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + # Track available for new address + self._unavailability_trackers[irk] = bluetooth.async_track_unavailable( + self.hass, self._async_track_unavailable, mac, False + ) + + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + mac = service_info.address + + if mac in self._ignored: + return + + if resolved := self._mac_to_irk.get(mac): + if callbacks := self._service_info_callbacks.get(resolved): + for cb in callbacks: + cb(service_info, change) + return + + for irk, cipher in self._irks.items(): + if resolve_private_address(cipher, service_info.address): + self._async_irk_resolved_to_mac(irk, mac) + if callbacks := self._service_info_callbacks.get(irk): + for cb in callbacks: + cb(service_info, change) + return + + def _unignore(service_info: bluetooth.BluetoothServiceInfoBleak) -> None: + self._ignored.pop(service_info.address, None) + + self._ignored[mac] = bluetooth.async_track_unavailable( + self.hass, _unignore, mac, False + ) + + def _async_maybe_learn_irk(self, irk: bytes) -> None: + """Add irk to list of irks that we can use to resolve RPAs.""" + if irk not in self._irks: + if service_info := async_last_service_info(self.hass, irk): + self._async_irk_resolved_to_mac(irk, service_info.address) + self._irks[irk] = get_cipher_for_irk(irk) + + def _async_maybe_forget_irk(self, irk: bytes) -> None: + """If no downstream caller is tracking this irk, lets forget it.""" + if irk in self._service_info_callbacks or irk in self._unavailable_callbacks: + return + + # Ignore availability events for this irk as no + # one is listening. + if cancel := self._unavailability_trackers.pop(irk, None): + cancel() + + del self._irks[irk] + + if mac := self._irk_to_mac.pop(irk, None): + self._mac_to_irk.pop(mac, None) + + if not self._mac_to_irk: + self._async_ensure_stopped() + + def async_track_service_info( + self, callback: bluetooth.BluetoothCallback, irk: bytes + ) -> Cancellable: + """Receive a callback when a new advertisement is received for an irk. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._service_info_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._service_info_callbacks.pop(irk, None) + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + def async_track_unavailable( + self, + callback: UnavailableCallback, + irk: bytes, + ) -> Cancellable: + """Register to receive a callback when an irk is unavailable. + + Returns a callback that can be used to cancel the registration. + """ + self._async_ensure_started() + self._async_maybe_learn_irk(irk) + + callbacks = self._unavailable_callbacks.setdefault(irk, []) + callbacks.append(callback) + + def _unsubscribe() -> None: + callbacks.remove(callback) + if not callbacks: + self._unavailable_callbacks.pop(irk, None) + + self._async_maybe_forget_irk(irk) + + return _unsubscribe + + +def async_get_coordinator(hass: HomeAssistant) -> PrivateDevicesCoordinator: + """Create or return an existing PrivateDeviceManager. + + There should only be one per HomeAssistant instance. Associating private + mac addresses with an IRK involves AES operations. We don't want to + duplicate that work. + """ + if existing := hass.data.get(DOMAIN): + return cast(PrivateDevicesCoordinator, existing) + + pdm = hass.data[DOMAIN] = PrivateDevicesCoordinator(hass) + + return pdm diff --git a/homeassistant/components/private_ble_device/device_tracker.py b/homeassistant/components/private_ble_device/device_tracker.py new file mode 100644 index 00000000000000..64e23b25ebec78 --- /dev/null +++ b/homeassistant/components/private_ble_device/device_tracker.py @@ -0,0 +1,75 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from collections.abc import Mapping +import logging + +from homeassistant.components import bluetooth +from homeassistant.components.device_tracker import SourceType +from homeassistant.components.device_tracker.config_entry import BaseTrackerEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_HOME, STATE_NOT_HOME +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Load Device Tracker entities for a config entry.""" + async_add_entities([BasePrivateDeviceTracker(config_entry)]) + + +class BasePrivateDeviceTracker(BasePrivateDeviceEntity, BaseTrackerEntity): + """A trackable Private Bluetooth Device.""" + + _attr_should_poll = False + _attr_has_entity_name = True + _attr_name = None + + @property + def extra_state_attributes(self) -> Mapping[str, str]: + """Return extra state attributes for this device.""" + if last_info := self._last_info: + return { + "current_address": last_info.address, + "source": last_info.source, + } + return {} + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + self._last_info = None + self.async_write_ha_state() + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + self._last_info = service_info + self.async_write_ha_state() + + @property + def state(self) -> str: + """Return the state of the device.""" + return STATE_HOME if self._last_info else STATE_NOT_HOME + + @property + def source_type(self) -> SourceType: + """Return the source type, eg gps or router, of the device.""" + return SourceType.BLUETOOTH_LE + + @property + def icon(self) -> str: + """Return device icon.""" + return "mdi:bluetooth-connect" if self._last_info else "mdi:bluetooth-off" diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py new file mode 100644 index 00000000000000..ae632213506609 --- /dev/null +++ b/homeassistant/components/private_ble_device/entity.py @@ -0,0 +1,71 @@ +"""Tracking for bluetooth low energy devices.""" +from __future__ import annotations + +from abc import abstractmethod +import binascii + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from .coordinator import async_get_coordinator, async_last_service_info + + +class BasePrivateDeviceEntity(Entity): + """Base Private Bluetooth Entity.""" + + _attr_should_poll = False + _attr_has_entity_name = True + + def __init__(self, config_entry: ConfigEntry) -> None: + """Set up a new BleScanner entity.""" + irk = config_entry.data["irk"] + + self._attr_unique_id = irk + + self._attr_device_info = DeviceInfo( + name=f"Private BLE Device {irk[:6]}", + identifiers={(DOMAIN, irk)}, + ) + + self._entry = config_entry + self._irk = binascii.unhexlify(irk) + self._last_info: bluetooth.BluetoothServiceInfoBleak | None = None + + async def async_added_to_hass(self) -> None: + """Configure entity when it is added to Home Assistant.""" + coordinator = async_get_coordinator(self.hass) + self.async_on_remove( + coordinator.async_track_service_info( + self._async_track_service_info, self._irk + ) + ) + self.async_on_remove( + coordinator.async_track_unavailable( + self._async_track_unavailable, self._irk + ) + ) + + if service_info := async_last_service_info(self.hass, self._irk): + self._async_track_service_info( + service_info, bluetooth.BluetoothChange.ADVERTISEMENT + ) + + @abstractmethod + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Respond when the bluetooth device being tracked is no longer visible.""" + + @abstractmethod + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Respond when the bluetooth device being tracked broadcasted updated information.""" diff --git a/homeassistant/components/private_ble_device/manifest.json b/homeassistant/components/private_ble_device/manifest.json new file mode 100644 index 00000000000000..3497138178cfba --- /dev/null +++ b/homeassistant/components/private_ble_device/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "private_ble_device", + "name": "Private BLE Device", + "codeowners": ["@Jc2k"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/private_ble_device", + "iot_class": "local_push", + "requirements": ["bluetooth-data-tools==1.11.0"] +} diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json new file mode 100644 index 00000000000000..c62ea5c4d50f01 --- /dev/null +++ b/homeassistant/components/private_ble_device/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "flow_title": "[%key:component::bluetooth::config::flow_title%]", + "step": { + "user": { + "description": "What is the IRK (Identity Resolving Key) of the BLE device you want to track?", + "data": { + "irk": "IRK" + } + } + }, + "error": { + "irk_not_found": "The provided IRK does not match any BLE devices that Home Assistant can see.", + "irk_not_valid": "The key does not look like a valid IRK." + }, + "abort": { + "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 7d84dc87cbe047..6c992fd4b5e4b7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -351,6 +351,7 @@ "point", "poolsense", "powerwall", + "private_ble_device", "profiler", "progettihwsw", "prosegur", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c357b5aed4c9aa..a9e19441693b57 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4320,6 +4320,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "private_ble_device": { + "name": "Private BLE Device", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "profiler": { "name": "Profiler", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 9802c26c3c6461..178b82fd359c0e 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2312,6 +2312,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.private_ble_device.*] +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.proximity.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index d72b41f442e08a..be7a06399d2a58 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -550,6 +550,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble +# homeassistant.components.private_ble_device bluetooth-data-tools==1.11.0 # homeassistant.components.bond diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3049cb33268d82..5362d5ac2b5e59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -461,6 +461,7 @@ bluetooth-auto-recovery==1.2.1 # homeassistant.components.esphome # homeassistant.components.ld2410_ble # homeassistant.components.led_ble +# homeassistant.components.private_ble_device bluetooth-data-tools==1.11.0 # homeassistant.components.bond diff --git a/tests/components/private_ble_device/__init__.py b/tests/components/private_ble_device/__init__.py new file mode 100644 index 00000000000000..df9929293a1526 --- /dev/null +++ b/tests/components/private_ble_device/__init__.py @@ -0,0 +1,78 @@ +"""Tests for private_ble_device.""" + +from datetime import timedelta +import time +from unittest.mock import patch + +from home_assistant_bluetooth import BluetoothServiceInfoBleak + +from homeassistant import config_entries +from homeassistant.components.private_ble_device.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.components.bluetooth import ( + generate_advertisement_data, + generate_ble_device, + inject_bluetooth_service_info_bleak, +) + +MAC_RPA_VALID_1 = "40:01:02:0a:c4:a6" +MAC_RPA_VALID_2 = "40:02:03:d2:74:ce" +MAC_RPA_INVALID = "40:00:00:d2:74:ce" +MAC_STATIC = "00:01:ff:a0:3a:76" + +DUMMY_IRK = "00000000000000000000000000000000" + + +async def async_mock_config_entry(hass: HomeAssistant, irk: str = DUMMY_IRK) -> None: + """Create a test device for a dummy IRK.""" + entry = MockConfigEntry( + version=1, + domain=DOMAIN, + entry_id=irk, + data={"irk": irk}, + title="Private BLE Device 000000", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + assert entry.state is config_entries.ConfigEntryState.LOADED + await hass.async_block_till_done() + + +async def async_inject_broadcast( + hass: HomeAssistant, + mac: str = MAC_RPA_VALID_1, + mfr_data: bytes = b"", + broadcast_time: float | None = None, +) -> None: + """Inject an advertisement.""" + inject_bluetooth_service_info_bleak( + hass, + BluetoothServiceInfoBleak( + name="Test Test Test", + address=mac, + rssi=-63, + service_data={}, + manufacturer_data={1: mfr_data}, + service_uuids=[], + source="local", + device=generate_ble_device(mac, "Test Test Test"), + advertisement=generate_advertisement_data(local_name="Not it"), + time=broadcast_time or time.monotonic(), + connectable=False, + ), + ) + await hass.async_block_till_done() + + +async def async_move_time_forwards(hass: HomeAssistant, offset: float): + """Mock time advancing from now to now+offset.""" + with patch( + "homeassistant.components.bluetooth.manager.MONOTONIC_TIME", + return_value=time.monotonic() + offset, + ): + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=offset)) + await hass.async_block_till_done() diff --git a/tests/components/private_ble_device/conftest.py b/tests/components/private_ble_device/conftest.py new file mode 100644 index 00000000000000..b33dc1d4ea2109 --- /dev/null +++ b/tests/components/private_ble_device/conftest.py @@ -0,0 +1 @@ +"""private_ble_device fixtures.""" diff --git a/tests/components/private_ble_device/test_config_flow.py b/tests/components/private_ble_device/test_config_flow.py new file mode 100644 index 00000000000000..aa8ea0d905c514 --- /dev/null +++ b/tests/components/private_ble_device/test_config_flow.py @@ -0,0 +1,132 @@ +"""Tests for private bluetooth device config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries +from homeassistant.components.private_ble_device import const +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResult, FlowResultType +from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo + +from tests.components.bluetooth import inject_bluetooth_service_info + + +def assert_form_error(result: FlowResult, key: str, value: str) -> None: + """Assert that a flow returned a form error.""" + assert result["type"] == "form" + assert result["errors"] + assert result["errors"][key] == value + + +async def test_setup_user_no_bluetooth( + hass: HomeAssistant, mock_bluetooth_adapters: None +) -> None: + """Test setting up via user interaction when bluetooth is not enabled.""" + result = await hass.config_entries.flow.async_init( + const.DOMAIN, + context={"source": config_entries.SOURCE_USER}, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "bluetooth_not_available" + + +async def test_invalid_irk(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:000000"} + ) + 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( + 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:00000000000000000000000000000000"}, + ) + assert_form_error(result, "irk", "irk_not_found") + + +async def test_flow_works(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "irk:00000000000000000000000000000000"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" + + +async def test_flow_works_by_base64( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test config flow works.""" + + inject_bluetooth_service_info( + hass, + BluetoothServiceInfo( + name="Test Test Test", + address="40:01:02:0a:c4:a6", + rssi=-63, + service_data={}, + manufacturer_data={}, + service_uuids=[], + source="local", + ), + ) + + result = await hass.config_entries.flow.async_init( + const.DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + + # Check you can finish the flow + with patch( + "homeassistant.components.private_ble_device.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={"irk": "AAAAAAAAAAAAAAAAAAAAAA=="}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Test Test Test" + assert result["data"] == {"irk": "00000000000000000000000000000000"} + assert result["result"].unique_id == "00000000000000000000000000000000" diff --git a/tests/components/private_ble_device/test_device_tracker.py b/tests/components/private_ble_device/test_device_tracker.py new file mode 100644 index 00000000000000..776ba503983e8f --- /dev/null +++ b/tests/components/private_ble_device/test_device_tracker.py @@ -0,0 +1,183 @@ +"""Tests for polling measures.""" + + +import time + +from homeassistant.components.bluetooth.advertisement_tracker import ( + ADVERTISING_TIMES_NEEDED, +) +from homeassistant.core import HomeAssistant + +from . import ( + MAC_RPA_VALID_1, + MAC_RPA_VALID_2, + MAC_STATIC, + async_inject_broadcast, + async_mock_config_entry, + async_move_time_forwards, +) + +from tests.components.bluetooth.test_advertisement_tracker import ONE_HOUR_SECONDS + + +async def test_tracker_created(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating a tracker entity when no devices have been seen.""" + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_ignore_other_rpa( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test that tracker ignores RPA's that don't match us.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_STATIC) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_already_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test creating a tracker and the device was already discovered by HA.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + +async def test_tracker_arrive_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test transition from not_home to home.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + assert state.attributes["source"] == "local" + + await async_inject_broadcast(hass, MAC_STATIC, b"1") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Test same wrong mac address again to exercise some caching + await async_inject_broadcast(hass, MAC_STATIC, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # And test original mac address again. + # Use different mfr data so that event bubbles up + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"2") + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == "40:01:02:0a:c4:a6" + + +async def test_tracker_isolation(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test creating 2 tracker entities doesn't confuse anything.""" + await async_mock_config_entry(hass) + await async_mock_config_entry(hass, irk="1" * 32) + + # This broadcast should only impact the first entity + await async_inject_broadcast(hass, MAC_RPA_VALID_1, b"1") + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + state = hass.states.get("device_tracker.private_ble_device_111111") + assert state + assert state.state == "not_home" + + +async def test_tracker_mac_rotate(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test MAC address rotation.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_1 + + await async_inject_broadcast(hass, MAC_RPA_VALID_2) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + assert state.attributes["current_address"] == MAC_RPA_VALID_2 + + +async def test_tracker_start_stale(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test edge case where we find an existing stale record, and it expires before we see any more.""" + time.monotonic() + + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_tracker_leave_home(hass: HomeAssistant, enable_bluetooth: None) -> None: + """Test tracker notices we have left.""" + time.monotonic() + + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + await async_move_time_forwards( + hass, ((ADVERTISING_TIMES_NEEDED - 1) * ONE_HOUR_SECONDS) + ) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" + + +async def test_old_tracker_leave_home( + hass: HomeAssistant, enable_bluetooth: None +) -> None: + """Test tracker ignores an old stale mac address timing out.""" + start_time = time.monotonic() + + await async_mock_config_entry(hass) + + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time) + await async_inject_broadcast(hass, MAC_RPA_VALID_2, broadcast_time=start_time + 15) + + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # First address has timed out - still home + await async_move_time_forwards(hass, 910) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "home" + + # Second address has time out - now away + await async_move_time_forwards(hass, 920) + state = hass.states.get("device_tracker.private_ble_device_000000") + assert state + assert state.state == "not_home" From 9e9aa163f70ada036c59f5a1dbd9df269ff704d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 2 Sep 2023 16:42:32 +0200 Subject: [PATCH 013/640] Use shorthand attributes in hlk_sw16 (#99383) --- homeassistant/components/hlk_sw16/__init__.py | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/hlk_sw16/__init__.py b/homeassistant/components/hlk_sw16/__init__.py index f80972da6130fb..9be0b5203fd904 100644 --- a/homeassistant/components/hlk_sw16/__init__.py +++ b/homeassistant/components/hlk_sw16/__init__.py @@ -147,12 +147,8 @@ def __init__(self, device_port, entry_id, client): self._device_port = device_port self._is_on = None self._client = client - self._name = device_port - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._entry_id}_{self._device_port}" + self._attr_name = device_port + self._attr_unique_id = f"{self._entry_id}_{self._device_port}" @callback def handle_event_callback(self, event): @@ -161,11 +157,6 @@ def handle_event_callback(self, event): self._is_on = event self.async_write_ha_state() - @property - def name(self): - """Return a name for the device.""" - return self._name - @property def available(self): """Return True if entity is available.""" From 7168e71860ff5c04aafbf5de240026b2963043ff Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Sep 2023 16:51:06 +0200 Subject: [PATCH 014/640] Log unexpected exceptions causing recorder shutdown (#99445) --- homeassistant/components/recorder/core.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index ffdc3807039d3a..bbaff24ff778fb 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -692,6 +692,10 @@ def run(self) -> None: """Run the recorder thread.""" try: self._run() + except Exception: # pylint: disable=broad-exception-caught + _LOGGER.exception( + "Recorder._run threw unexpected exception, recorder shutting down" + ) finally: # Ensure shutdown happens cleanly if # anything goes wrong in the run loop From defd9e400179f568a053cf4c86471830e03206fc Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Sat, 2 Sep 2023 17:09:46 +0200 Subject: [PATCH 015/640] Don't compile missing statistics when running tests (#99446) --- tests/conftest.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index f90984e1c7bc8f..739dfa5d292937 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1276,6 +1276,11 @@ def hass_recorder( hass = get_test_home_assistant() nightly = recorder.Recorder.async_nightly_tasks if enable_nightly_purge else None stats = recorder.Recorder.async_periodic_statistics if enable_statistics else None + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) schema_validate = ( migration._find_schema_errors if enable_schema_validation @@ -1327,6 +1332,10 @@ def hass_recorder( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): def setup_recorder(config: dict[str, Any] | None = None) -> HomeAssistant: @@ -1399,6 +1408,11 @@ async def async_setup_recorder_instance( if enable_schema_validation else itertools.repeat(set()) ) + compile_missing = ( + recorder.Recorder._schedule_compile_missing_statistics + if enable_statistics + else None + ) migrate_states_context_ids = ( recorder.Recorder._migrate_states_context_ids if enable_migrate_context_ids @@ -1445,6 +1459,10 @@ async def async_setup_recorder_instance( "homeassistant.components.recorder.Recorder._migrate_entity_ids", side_effect=migrate_entity_ids, autospec=True, + ), patch( + "homeassistant.components.recorder.Recorder._schedule_compile_missing_statistics", + side_effect=compile_missing, + autospec=True, ): async def async_setup_recorder( From 6e743a5bb2b7a56cd68d0663943ea6e220e141d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 11:55:11 -0500 Subject: [PATCH 016/640] Switch mqtt to use async_call_later where possible (#99486) --- homeassistant/components/mqtt/binary_sensor.py | 18 +++++++++--------- homeassistant/components/mqtt/sensor.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 0d4b2c4a7b4572..b5c7bc987896f4 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -128,15 +128,17 @@ async def mqtt_async_added_to_hass(self) -> None: expiration_at: datetime = last_state.last_changed + timedelta( seconds=self._expire_after ) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the binary_sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_is_on = last_state.state == STATE_ON - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -144,7 +146,7 @@ async def mqtt_async_added_to_hass(self) -> None: " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -202,10 +204,8 @@ def state_message_received(msg: ReceiveMessage) -> None: self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._value_template(msg.payload) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index ae94b0df0ce68c..70c8d505b4f8f2 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -32,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_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util @@ -162,15 +162,17 @@ async def mqtt_async_added_to_hass(self) -> None: and not self._expiration_trigger ): expiration_at = last_state.last_changed + timedelta(seconds=_expire_after) - if expiration_at < (time_now := dt_util.utcnow()): + remain_seconds = (expiration_at - dt_util.utcnow()).total_seconds() + + if remain_seconds <= 0: # Skip reactivating the sensor _LOGGER.debug("Skip state recovery after reload for %s", self.entity_id) return self._expired = False self._attr_native_value = last_sensor_data.native_value - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, remain_seconds, self._value_is_expired ) _LOGGER.debug( ( @@ -178,7 +180,7 @@ async def mqtt_async_added_to_hass(self) -> None: " expiring %s" ), self.entity_id, - expiration_at - time_now, + remain_seconds, ) async def async_will_remove_from_hass(self) -> None: @@ -235,10 +237,8 @@ def _update_state(msg: ReceiveMessage) -> None: self._expiration_trigger() # Set new trigger - expiration_at = dt_util.utcnow() + timedelta(seconds=self._expire_after) - - self._expiration_trigger = async_track_point_in_utc_time( - self.hass, self._value_is_expired, expiration_at + self._expiration_trigger = async_call_later( + self.hass, self._expire_after, self._value_is_expired ) payload = self._template(msg.payload, PayloadSentinel.DEFAULT) From acd9cfa929646a6a1fcad807e3e4b5c6c02565d4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:08:07 -0500 Subject: [PATCH 017/640] Speed up calls to the all states api (#99462) --- homeassistant/components/api/__init__.py | 21 +++++++++----- tests/components/api/test_init.py | 37 ++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7b13833ccaba1e..b427341546e5d1 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -9,6 +9,7 @@ from aiohttp.web_exceptions import HTTPBadRequest import voluptuous as vol +from homeassistant.auth.models import User from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView, require_admin @@ -189,16 +190,20 @@ class APIStatesView(HomeAssistantView): name = "api:states" @ha.callback - def get(self, request): + def get(self, request: web.Request) -> web.Response: """Get current states.""" - user = request["hass_user"] + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] + if user.is_admin: + return self.json([state.as_dict() for state in hass.states.async_all()]) entity_perm = user.permissions.check_entity - states = [ - state - for state in request.app["hass"].states.async_all() - if entity_perm(state.entity_id, "read") - ] - return self.json(states) + return self.json( + [ + state.as_dict() + for state in hass.states.async_all() + if entity_perm(state.entity_id, "read") + ] + ) class APIEntityStateView(HomeAssistantView): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 116529b02a4515..f61988eff5a270 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -9,6 +9,7 @@ import voluptuous as vol from homeassistant import const +from homeassistant.auth.models import Credentials from homeassistant.auth.providers.legacy_api_password import ( LegacyApiPasswordAuthProvider, ) @@ -17,7 +18,7 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockUser, async_mock_service +from tests.common import CLIENT_ID, MockUser, async_mock_service from tests.typing import ClientSessionGenerator @@ -569,11 +570,41 @@ async def test_event_stream_requires_admin( assert resp.status == HTTPStatus.UNAUTHORIZED -async def test_states_view_filters( +async def test_states( hass: HomeAssistant, mock_api_client: TestClient, hass_admin_user: MockUser +) -> None: + """Test fetching all states as admin.""" + hass.states.async_set("test.entity", "hello") + resp = await mock_api_client.get(const.URL_API_STATES) + assert resp.status == HTTPStatus.OK + json = await resp.json() + assert len(json) == 1 + assert json[0]["entity_id"] == "test.entity" + + +async def test_states_view_filters( + hass: HomeAssistant, + hass_read_only_user: MockUser, + hass_client: ClientSessionGenerator, ) -> None: """Test filtering only visible states.""" - hass_admin_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + assert not hass_read_only_user.is_admin + hass_read_only_user.mock_policy({"entities": {"entity_ids": {"test.entity": True}}}) + await async_setup_component(hass, "api", {}) + read_only_user_credential = Credentials( + id="mock-read-only-credential-id", + auth_provider_type="homeassistant", + auth_provider_id=None, + data={"username": "readonly"}, + is_new=False, + ) + await hass.auth.async_link_user(hass_read_only_user, read_only_user_credential) + + refresh_token = await hass.auth.async_create_refresh_token( + hass_read_only_user, CLIENT_ID, credential=read_only_user_credential + ) + token = hass.auth.async_create_access_token(refresh_token) + mock_api_client = await hass_client(token) hass.states.async_set("test.entity", "hello") hass.states.async_set("test.not_visible_entity", "invisible") resp = await mock_api_client.get(const.URL_API_STATES) From 6974d211e55cbcd042c3216c9c0c38801e41f01e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:14:33 -0500 Subject: [PATCH 018/640] Switch isy994 to use async_call_later (#99487) async_track_point_in_utc_time is not needed here since we only need to call at timedelta(hours=25) --- homeassistant/components/isy994/binary_sensor.py | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index aa7c3d551473bf..27f1887bd92a47 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -23,9 +23,8 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.event import async_track_point_in_utc_time +from homeassistant.helpers.event import async_call_later from homeassistant.helpers.restore_state import RestoreEntity -from homeassistant.util import dt as dt_util from .const import ( _LOGGER, @@ -496,15 +495,8 @@ def timer_elapsed(now: datetime) -> None: self._heartbeat_timer = None self.async_write_ha_state() - point_in_time = dt_util.utcnow() + timedelta(hours=25) - _LOGGER.debug( - "Heartbeat timer starting. Now: %s Then: %s", - dt_util.utcnow(), - point_in_time, - ) - - self._heartbeat_timer = async_track_point_in_utc_time( - self.hass, timer_elapsed, point_in_time + self._heartbeat_timer = async_call_later( + self.hass, timedelta(hours=25), timer_elapsed ) @callback From c3841f8734b24aa68045d4172b04149c34ddb98e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 2 Sep 2023 19:26:11 +0200 Subject: [PATCH 019/640] Rework on mqtt certificate tests (#99503) * Shared fixture on TEMP_DIR_NAME mock in MQTT tests * Improve mqtt certificate file tests * Update tests/components/mqtt/test_util.py Co-authored-by: J. Nick Koston * Update tests/components/mqtt/conftest.py Co-authored-by: J. Nick Koston * Avoid blocking code * typo in sub function --------- Co-authored-by: J. Nick Koston --- tests/components/mqtt/conftest.py | 21 +++++++ tests/components/mqtt/test_config_flow.py | 11 ++-- tests/components/mqtt/test_util.py | 74 ++++++++++++++++------- 3 files changed, 77 insertions(+), 29 deletions(-) diff --git a/tests/components/mqtt/conftest.py b/tests/components/mqtt/conftest.py index ebe86c1f1dfb7d..91ece381f6d463 100644 --- a/tests/components/mqtt/conftest.py +++ b/tests/components/mqtt/conftest.py @@ -1,5 +1,9 @@ """Test fixtures for mqtt component.""" +from collections.abc import Generator +from random import getrandbits +from unittest.mock import patch + import pytest from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -8,3 +12,20 @@ @pytest.fixture(autouse=True) def patch_hass_config(mock_hass_config: None) -> None: """Patch configuration.yaml.""" + + +@pytest.fixture +def temp_dir_prefix() -> str: + """Set an alternate temp dir prefix.""" + return "test" + + +@pytest.fixture +def mock_temp_dir(temp_dir_prefix: str) -> Generator[None, None, str]: + """Mock the certificate temp directory.""" + with patch( + # Patch temp dir name to avoid tests fail running in parallel + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-{temp_dir_prefix}-{getrandbits(10):03x}", + ) as mocked_temp_dir: + yield mocked_temp_dir diff --git a/tests/components/mqtt/test_config_flow.py b/tests/components/mqtt/test_config_flow.py index f0681a537da557..c2a7e0065ce04f 100644 --- a/tests/components/mqtt/test_config_flow.py +++ b/tests/components/mqtt/test_config_flow.py @@ -2,7 +2,6 @@ from collections.abc import Generator, Iterator from contextlib import contextmanager from pathlib import Path -from random import getrandbits from ssl import SSLError from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -131,7 +130,9 @@ def mock_try_connection_time_out() -> Generator[MagicMock, None, None]: @pytest.fixture -def mock_process_uploaded_file(tmp_path: Path) -> Generator[MagicMock, None, None]: +def mock_process_uploaded_file( + tmp_path: Path, mock_temp_dir: str +) -> Generator[MagicMock, None, None]: """Mock upload certificate files.""" file_id_ca = str(uuid4()) file_id_cert = str(uuid4()) @@ -159,11 +160,7 @@ def _mock_process_uploaded_file( with patch( "homeassistant.components.mqtt.config_flow.process_uploaded_file", side_effect=_mock_process_uploaded_file, - ) as mock_upload, patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ): + ) as mock_upload: mock_upload.file_id = { mqtt.CONF_CERTIFICATE: file_id_ca, mqtt.CONF_CLIENT_CERT: file_id_cert, diff --git a/tests/components/mqtt/test_util.py b/tests/components/mqtt/test_util.py index e93a5e376bbb96..941072bc224e18 100644 --- a/tests/components/mqtt/test_util.py +++ b/tests/components/mqtt/test_util.py @@ -1,7 +1,9 @@ """Test MQTT utils.""" from collections.abc import Callable +from pathlib import Path from random import getrandbits +import tempfile from unittest.mock import patch import pytest @@ -14,17 +16,6 @@ from tests.typing import MqttMockHAClient, MqttMockPahoClient -@pytest.fixture(autouse=True) -def mock_temp_dir(): - """Mock the certificate temp directory.""" - with patch( - # Patch temp dir name to avoid tests fail running in parallel - "homeassistant.components.mqtt.util.TEMP_DIR_NAME", - "home-assistant-mqtt" + f"-{getrandbits(10):03x}", - ) as mocked_temp_dir: - yield mocked_temp_dir - - @pytest.mark.parametrize( ("option", "content", "file_created"), [ @@ -34,31 +25,50 @@ def mock_temp_dir(): (mqtt.CONF_CLIENT_KEY, "### PRIVATE KEY ###", True), ], ) +@pytest.mark.parametrize("temp_dir_prefix", ["create-test"]) async def test_async_create_certificate_temp_files( - hass: HomeAssistant, mock_temp_dir, option, content, file_created + 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} - await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path = mqtt.util.get_file_path(option) + 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) + 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 ( - mqtt.util.migrate_certificate_file_to_content(file_path or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path or content + ) + == content ) # Make sure certificate temp files are recovered - if file_path: - # Overwrite content of file (except for auto option) - file = open(file_path, "wb") - file.write(b"invalid") - file.close() + await hass.async_add_executor_job(_ensure_old_file_exists) await mqtt.util.async_create_certificate_temp_files(hass, config) - file_path2 = mqtt.util.get_file_path(option) + file_path2 = await hass.async_add_executor_job(mqtt.util.get_file_path, option) assert bool(file_path2) is file_created assert ( - mqtt.util.migrate_certificate_file_to_content(file_path2 or content) == content + await hass.async_add_executor_job( + mqtt.util.migrate_certificate_file_to_content, file_path2 or content + ) + == content ) assert file_path == file_path2 @@ -71,6 +81,26 @@ async def test_reading_non_exitisting_certificate_file() -> None: ) +@pytest.mark.parametrize("temp_dir_prefix", "unknown") +async def test_return_default_get_file_path( + hass: HomeAssistant, mock_temp_dir: str +) -> None: + """Test get_file_path returns default.""" + + def _get_file_path(file_path: Path) -> bool: + return ( + not file_path.exists() + and mqtt.util.get_file_path("some_option", "mydefault") == "mydefault" + ) + + with patch( + "homeassistant.components.mqtt.util.TEMP_DIR_NAME", + f"home-assistant-mqtt-other-{getrandbits(10):03x}", + ) as mock_temp_dir: + tempdir = Path(tempfile.gettempdir()) / mock_temp_dir + assert await hass.async_add_executor_job(_get_file_path, tempdir) + + @patch("homeassistant.components.mqtt.PLATFORMS", []) async def test_waiting_for_client_not_loaded( hass: HomeAssistant, From 1048f47a915c3b89bdcac5dca54320b7535e1431 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Sep 2023 10:38:41 -0700 Subject: [PATCH 020/640] Code cleanup for nest device info (#99511) --- homeassistant/components/nest/device_info.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/nest/device_info.py b/homeassistant/components/nest/device_info.py index 35e32ccf1bc98f..f269e3e89d648d 100644 --- a/homeassistant/components/nest/device_info.py +++ b/homeassistant/components/nest/device_info.py @@ -66,10 +66,7 @@ def device_name(self) -> str | None: @property def device_model(self) -> str | None: """Return device model information.""" - # The API intentionally returns minimal information about specific - # devices, instead relying on traits, but we can infer a generic model - # name based on the type - return DEVICE_TYPE_MAP.get(self._device.type or "", None) + return DEVICE_TYPE_MAP.get(self._device.type) if self._device.type else None @property def suggested_area(self) -> str | None: From 1ab2e900f9a61f35be4dcaa2551140d7a5cd79b3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 12:43:27 -0500 Subject: [PATCH 021/640] Improve lingering timer checks (#99472) --- homeassistant/core.py | 4 ++++ tests/conftest.py | 35 +++++++++++++++++++++++++++-------- 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 18c5c355ae979f..89269ae9158cf9 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -294,6 +294,10 @@ def __new__(cls, config_dir: str) -> HomeAssistant: _hass.hass = hass return hass + def __repr__(self) -> str: + """Return the representation.""" + return f"" + def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" self.loop = asyncio.get_running_loop() diff --git a/tests/conftest.py b/tests/conftest.py index 739dfa5d292937..99db088449670c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,12 +3,13 @@ import asyncio from collections.abc import AsyncGenerator, Callable, Coroutine, Generator -from contextlib import asynccontextmanager +from contextlib import asynccontextmanager, contextmanager import functools import gc import itertools import logging import os +import reprlib import sqlite3 import ssl import threading @@ -302,6 +303,21 @@ def skip_stop_scripts( yield +@contextmanager +def long_repr_strings() -> Generator[None, None, None]: + """Increase reprlib maxstring and maxother to 300.""" + arepr = reprlib.aRepr + original_maxstring = arepr.maxstring + original_maxother = arepr.maxother + arepr.maxstring = 300 + arepr.maxother = 300 + try: + yield + finally: + arepr.maxstring = original_maxstring + arepr.maxother = original_maxother + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, @@ -335,13 +351,16 @@ def verify_cleanup( for handle in event_loop._scheduled: # type: ignore[attr-defined] if not handle.cancelled(): - if expected_lingering_timers: - _LOGGER.warning("Lingering timer after test %r", handle) - elif handle._args and isinstance(job := handle._args[0], HassJob): - pytest.fail(f"Lingering timer after job {repr(job)}") - else: - pytest.fail(f"Lingering timer after test {repr(handle)}") - handle.cancel() + with long_repr_strings(): + if expected_lingering_timers: + _LOGGER.warning("Lingering timer after test %r", handle) + elif handle._args and isinstance(job := handle._args[-1], HassJob): + if job.cancel_on_shutdown: + continue + pytest.fail(f"Lingering timer after job {repr(job)}") + else: + pytest.fail(f"Lingering timer after test {repr(handle)}") + handle.cancel() # Verify no threads where left behind. threads = frozenset(threading.enumerate()) - threads_before From 834f3810d325fbd2f585e62fb4042968156e0dd6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 21:00:33 +0200 Subject: [PATCH 022/640] Check new IP of Reolink camera from DHCP (#99381) Co-authored-by: J. Nick Koston --- .../components/reolink/config_flow.py | 44 +++++++++- homeassistant/components/reolink/util.py | 23 +++++ tests/components/reolink/test_config_flow.py | 85 ++++++++++++++++--- 3 files changed, 137 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/reolink/util.py diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d24fd8d1f14ba6..d924f395c509c4 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -12,13 +12,14 @@ from homeassistant.components import dhcp from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import callback -from homeassistant.data_entry_flow import FlowResult +from homeassistant.data_entry_flow import AbortFlow, FlowResult from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost +from .util import has_connection_problem _LOGGER = logging.getLogger(__name__) @@ -96,7 +97,46 @@ async def async_step_reauth_confirm( async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult: """Handle discovery via dhcp.""" mac_address = format_mac(discovery_info.macaddress) - await self.async_set_unique_id(mac_address) + existing_entry = await self.async_set_unique_id(mac_address) + if ( + existing_entry + and CONF_PASSWORD in existing_entry.data + and existing_entry.data[CONF_HOST] != discovery_info.ip + ): + if has_connection_problem(self.hass, existing_entry): + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but connection to camera seems to be okay, so sticking to IP '%s'", + discovery_info.ip, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + + # check if the camera is reachable at the new IP + host = ReolinkHost(self.hass, existing_entry.data, existing_entry.options) + try: + await host.api.get_state("GetLocalLink") + await host.api.logout() + except ReolinkError as err: + _LOGGER.debug( + "Reolink DHCP reported new IP '%s', " + "but got error '%s' trying to connect, so sticking to IP '%s'", + discovery_info.ip, + str(err), + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") from err + if format_mac(host.api.mac_address) != mac_address: + _LOGGER.debug( + "Reolink mac address '%s' at new IP '%s' from DHCP, " + "does not match mac '%s' of config entry, so sticking to IP '%s'", + format_mac(host.api.mac_address), + discovery_info.ip, + mac_address, + existing_entry.data[CONF_HOST], + ) + raise AbortFlow("already_configured") + self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip}) self.context["title_placeholders"] = { diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py new file mode 100644 index 00000000000000..2ab625647a7c28 --- /dev/null +++ b/homeassistant/components/reolink/util.py @@ -0,0 +1,23 @@ +"""Utility functions for the Reolink component.""" +from __future__ import annotations + +from homeassistant import config_entries +from homeassistant.core import HomeAssistant + +from . import ReolinkData +from .const import DOMAIN + + +def has_connection_problem( + hass: HomeAssistant, config_entry: config_entries.ConfigEntry +) -> bool: + """Check if a existing entry has a connection problem.""" + reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( + config_entry.entry_id + ) + connection_problem = ( + reolink_data is not None + and config_entry.state == config_entries.ConfigEntryState.LOADED + and reolink_data.device_coordinator.last_update_success + ) + return connection_problem diff --git a/tests/components/reolink/test_config_flow.py b/tests/components/reolink/test_config_flow.py index 048b48d9576a39..1a4bf999ccebca 100644 --- a/tests/components/reolink/test_config_flow.py +++ b/tests/components/reolink/test_config_flow.py @@ -1,18 +1,22 @@ """Test the Reolink config flow.""" +from datetime import timedelta import json -from unittest.mock import MagicMock +from typing import Any +from unittest.mock import AsyncMock, MagicMock import pytest from reolink_aio.exceptions import ApiError, CredentialsInvalidError, ReolinkError from homeassistant import config_entries, data_entry_flow from homeassistant.components import dhcp -from homeassistant.components.reolink import const +from homeassistant.components.reolink import DEVICE_UPDATE_INTERVAL, const from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL from homeassistant.components.reolink.exceptions import ReolinkWebhookException +from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import format_mac +from homeassistant.util.dt import utcnow from .conftest import ( TEST_HOST, @@ -27,12 +31,14 @@ TEST_USERNAME2, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed -pytestmark = pytest.mark.usefixtures("mock_setup_entry", "reolink_connect") +pytestmark = pytest.mark.usefixtures("reolink_connect") -async def test_config_flow_manual_success(hass: HomeAssistant) -> None: +async def test_config_flow_manual_success( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Successful flow manually initialized by the user.""" result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -66,7 +72,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None: async def test_config_flow_errors( - hass: HomeAssistant, reolink_connect: MagicMock + hass: HomeAssistant, reolink_connect: MagicMock, mock_setup_entry: MagicMock ) -> None: """Successful flow manually initialized by the user after some errors.""" result = await hass.config_entries.flow.async_init( @@ -192,7 +198,7 @@ async def test_config_flow_errors( } -async def test_options_flow(hass: HomeAssistant) -> None: +async def test_options_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test specifying non default settings using options flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -230,7 +236,9 @@ async def test_options_flow(hass: HomeAssistant) -> None: } -async def test_change_connection_settings(hass: HomeAssistant) -> None: +async def test_change_connection_settings( + hass: HomeAssistant, mock_setup_entry: MagicMock +) -> None: """Test changing connection settings by issuing a second user config flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -273,7 +281,7 @@ async def test_change_connection_settings(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_reauth(hass: HomeAssistant) -> None: +async def test_reauth(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Test a reauth flow.""" config_entry = MockConfigEntry( domain=const.DOMAIN, @@ -333,7 +341,7 @@ async def test_reauth(hass: HomeAssistant) -> None: assert config_entry.data[CONF_PASSWORD] == TEST_PASSWORD2 -async def test_dhcp_flow(hass: HomeAssistant) -> None: +async def test_dhcp_flow(hass: HomeAssistant, mock_setup_entry: MagicMock) -> None: """Successful flow from DHCP discovery.""" dhcp_data = dhcp.DhcpServiceInfo( ip=TEST_HOST, @@ -371,8 +379,44 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None: } -async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: - """Test dhcp discovery aborts if already configured.""" +@pytest.mark.parametrize( + ("last_update_success", "attr", "value", "expected"), + [ + ( + False, + None, + None, + TEST_HOST2, + ), + ( + True, + None, + None, + TEST_HOST, + ), + ( + False, + "get_state", + AsyncMock(side_effect=ReolinkError("Test error")), + TEST_HOST, + ), + ( + False, + "mac_address", + "aa:aa:aa:aa:aa:aa", + TEST_HOST, + ), + ], +) +async def test_dhcp_ip_update( + hass: HomeAssistant, + reolink_connect: MagicMock, + last_update_success: bool, + attr: str, + value: Any, + expected: str, +) -> None: + """Test dhcp discovery aborts if already configured where the IP is updated if appropriate.""" config_entry = MockConfigEntry( domain=const.DOMAIN, unique_id=format_mac(TEST_MAC), @@ -392,16 +436,31 @@ async def test_dhcp_abort_flow(hass: HomeAssistant) -> None: assert await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.LOADED + + if not last_update_success: + # ensure the last_update_succes is False for the device_coordinator. + reolink_connect.get_states = AsyncMock(side_effect=ReolinkError("Test error")) + async_fire_time_changed( + hass, utcnow() + DEVICE_UPDATE_INTERVAL + timedelta(minutes=1) + ) + await hass.async_block_till_done() dhcp_data = dhcp.DhcpServiceInfo( - ip=TEST_HOST, + ip=TEST_HOST2, hostname="Reolink", macaddress=TEST_MAC, ) + if attr is not None: + setattr(reolink_connect, attr, value) + result = await hass.config_entries.flow.async_init( const.DOMAIN, context={"source": config_entries.SOURCE_DHCP}, data=dhcp_data ) assert result["type"] is data_entry_flow.FlowResultType.ABORT assert result["reason"] == "already_configured" + + await hass.async_block_till_done() + assert config_entry.data[CONF_HOST] == expected From 0b065b118b5481502e50b37f2a748960fc925698 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 14:04:13 -0500 Subject: [PATCH 023/640] Bump zeroconf to 0.91.1 (#99490) --- 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 79b7e514f51088..26577bd0bbe491 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.88.0"] + "requirements": ["zeroconf==0.91.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 286bc927d452ba..8069d5c0e70986 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.88.0 +zeroconf==0.91.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 be7a06399d2a58..9898a0b8f46f5c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5362d5ac2b5e59..d2a3bb2e718e8b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.88.0 +zeroconf==0.91.1 # homeassistant.components.zeversolar zeversolar==0.3.1 From 3c30ad1850bc52761c74fa2f0094845c30b49418 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 21:51:58 +0200 Subject: [PATCH 024/640] Motion blinds duplication reduction using entity baseclass (#99444) --- .coveragerc | 1 + .../components/motion_blinds/__init__.py | 23 +---- .../components/motion_blinds/const.py | 1 - .../components/motion_blinds/cover.py | 56 ++--------- .../components/motion_blinds/entity.py | 94 +++++++++++++++++++ .../components/motion_blinds/sensor.py | 84 +++-------------- 6 files changed, 117 insertions(+), 142 deletions(-) create mode 100644 homeassistant/components/motion_blinds/entity.py diff --git a/.coveragerc b/.coveragerc index d5a491a330f3ac..d28878d8861fd4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -750,6 +750,7 @@ omit = homeassistant/components/moehlenhoff_alpha2/sensor.py homeassistant/components/motion_blinds/__init__.py homeassistant/components/motion_blinds/cover.py + homeassistant/components/motion_blinds/entity.py homeassistant/components/motion_blinds/sensor.py homeassistant/components/mpd/media_player.py homeassistant/components/mqtt_room/sensor.py diff --git a/homeassistant/components/motion_blinds/__init__.py b/homeassistant/components/motion_blinds/__init__.py index 9ea0f6ddbc9ec7..188f3a784acba1 100644 --- a/homeassistant/components/motion_blinds/__init__.py +++ b/homeassistant/components/motion_blinds/__init__.py @@ -5,13 +5,12 @@ from socket import timeout from typing import TYPE_CHECKING, Any -from motionblinds import DEVICE_TYPES_WIFI, AsyncMotionMulticast, ParseException +from motionblinds import AsyncMotionMulticast, ParseException from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import CONF_API_KEY, CONF_HOST, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import ( @@ -27,8 +26,6 @@ KEY_MULTICAST_LISTENER, KEY_SETUP_LOCK, KEY_UNSUB_STOP, - KEY_VERSION, - MANUFACTURER, PLATFORMS, UPDATE_INTERVAL, UPDATE_INTERVAL_FAST, @@ -183,32 +180,14 @@ def stop_motion_multicast(event): # Fetch initial data so we have data when entities subscribe await coordinator.async_config_entry_first_refresh() - if motion_gateway.firmware is not None: - version = f"{motion_gateway.firmware}, protocol: {motion_gateway.protocol}" - else: - version = f"Protocol: {motion_gateway.protocol}" - hass.data[DOMAIN][entry.entry_id] = { KEY_GATEWAY: motion_gateway, KEY_COORDINATOR: coordinator, - KEY_VERSION: version, } if TYPE_CHECKING: assert entry.unique_id is not None - if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, motion_gateway.mac)}, - identifiers={(DOMAIN, motion_gateway.mac)}, - manufacturer=MANUFACTURER, - name=entry.title, - model="Wi-Fi bridge", - sw_version=version, - ) - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(update_listener)) diff --git a/homeassistant/components/motion_blinds/const.py b/homeassistant/components/motion_blinds/const.py index d241f03a02e913..429259a91c1bc9 100644 --- a/homeassistant/components/motion_blinds/const.py +++ b/homeassistant/components/motion_blinds/const.py @@ -18,7 +18,6 @@ KEY_MULTICAST_LISTENER = "multicast_listener" KEY_SETUP_LOCK = "setup_lock" KEY_UNSUB_STOP = "unsub_stop" -KEY_VERSION = "version" ATTR_WIDTH = "width" ATTR_ABSOLUTE_POSITION = "absolute_position" diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index c9578380048432..1a4507f10668a2 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -16,15 +16,9 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.helpers import ( - config_validation as cv, - device_registry as dr, - entity_platform, -) -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import config_validation as cv, entity_platform from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ( ATTR_ABSOLUTE_POSITION, @@ -33,13 +27,12 @@ DOMAIN, KEY_COORDINATOR, KEY_GATEWAY, - KEY_VERSION, - MANUFACTURER, SERVICE_SET_ABSOLUTE_POSITION, UPDATE_DELAY_STOP, UPDATE_INTERVAL_MOVING, UPDATE_INTERVAL_MOVING_WIFI, ) +from .entity import MotionCoordinatorEntity from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -96,7 +89,6 @@ async def async_setup_entry( entities = [] motion_gateway = hass.data[DOMAIN][config_entry.entry_id][KEY_GATEWAY] coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] - sw_version = hass.data[DOMAIN][config_entry.entry_id][KEY_VERSION] for blind in motion_gateway.device_list.values(): if blind.type in POSITION_DEVICE_MAP: @@ -105,7 +97,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -115,7 +106,6 @@ async def async_setup_entry( coordinator, blind, TILT_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -125,7 +115,6 @@ async def async_setup_entry( coordinator, blind, TILT_ONLY_DEVICE_MAP[blind.type], - sw_version, ) ) @@ -135,7 +124,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Top", ) ) @@ -144,7 +132,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Bottom", ) ) @@ -153,7 +140,6 @@ async def async_setup_entry( coordinator, blind, TDBU_DEVICE_MAP[blind.type], - sw_version, "Combined", ) ) @@ -168,7 +154,6 @@ async def async_setup_entry( coordinator, blind, POSITION_DEVICE_MAP[BlindType.RollerBlind], - sw_version, ) ) @@ -182,44 +167,27 @@ async def async_setup_entry( ) -class MotionPositionDevice(CoordinatorEntity, CoverEntity): +class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): """Representation of a Motion Blind Device.""" _restore_tilt = False - def __init__(self, coordinator, blind, device_class, sw_version): + def __init__(self, coordinator, blind, device_class): """Initialize the blind.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - self._blind = blind - self._api_lock = coordinator.api_lock self._requesting_position: CALLBACK_TYPE | None = None self._previous_positions = [] if blind.device_type in DEVICE_TYPES_WIFI: self._update_interval_moving = UPDATE_INTERVAL_MOVING_WIFI - via_device = () - connections = {(dr.CONNECTION_NETWORK_MAC, blind.mac)} else: self._update_interval_moving = UPDATE_INTERVAL_MOVING - via_device = (DOMAIN, blind._gateway.mac) - connections = {} - sw_version = None name = device_name(blind) self._attr_device_class = device_class self._attr_name = name self._attr_unique_id = blind.mac - self._attr_device_info = DeviceInfo( - connections=connections, - identifiers={(DOMAIN, blind.mac)}, - manufacturer=MANUFACTURER, - model=blind.blind_type, - name=name, - via_device=via_device, - sw_version=sw_version, - hw_version=blind.wireless_name, - ) @property def available(self) -> bool: @@ -249,16 +217,6 @@ def is_closed(self) -> bool | None: return None return self._blind.position == 100 - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes and register signal handler.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - async def async_scheduled_update_request(self, *_): """Request a state update from the blind at a scheduled point in time.""" # add the last position to the list and keep the list at max 2 items @@ -439,9 +397,9 @@ async def async_set_absolute_position(self, **kwargs): class MotionTDBUDevice(MotionPositionDevice): """Representation of a Motion Top Down Bottom Up blind Device.""" - def __init__(self, coordinator, blind, device_class, sw_version, motor): + def __init__(self, coordinator, blind, device_class, motor): """Initialize the blind.""" - super().__init__(coordinator, blind, device_class, sw_version) + super().__init__(coordinator, blind, device_class) self._motor = motor self._motor_key = motor[0] self._attr_name = f"{device_name(blind)} {motor}" diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py new file mode 100644 index 00000000000000..d57d7401b473eb --- /dev/null +++ b/homeassistant/components/motion_blinds/entity.py @@ -0,0 +1,94 @@ +"""Support for Motion Blinds using their WLAN API.""" +from __future__ import annotations + +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, MotionGateway +from motionblinds.motion_blinds import MotionBlind + +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import DataUpdateCoordinatorMotionBlinds +from .const import ( + ATTR_AVAILABLE, + DEFAULT_GATEWAY_NAME, + DOMAIN, + KEY_GATEWAY, + MANUFACTURER, +) +from .gateway import device_name + + +class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): + """Representation of a Motion Blind entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinatorMotionBlinds, + blind: MotionGateway | MotionBlind, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + + self._blind = blind + self._api_lock = coordinator.api_lock + + if blind.device_type in DEVICE_TYPES_GATEWAY: + gateway = blind + else: + gateway = blind._gateway + if gateway.firmware is not None: + sw_version = f"{gateway.firmware}, protocol: {gateway.protocol}" + else: + sw_version = f"Protocol: {gateway.protocol}" + + if blind.device_type in DEVICE_TYPES_GATEWAY: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + name=DEFAULT_GATEWAY_NAME, + model="Wi-Fi bridge", + sw_version=sw_version, + ) + elif blind.device_type in DEVICE_TYPES_WIFI: + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, blind.mac)}, + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + sw_version=sw_version, + hw_version=blind.wireless_name, + ) + else: + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, blind.mac)}, + manufacturer=MANUFACTURER, + model=blind.blind_type, + name=device_name(blind), + via_device=(DOMAIN, blind._gateway.mac), + hw_version=blind.wireless_name, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.coordinator.data is None: + return False + + gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] + if not gateway_available or self._blind.device_type in DEVICE_TYPES_GATEWAY: + return gateway_available + + return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] + + async def async_added_to_hass(self) -> None: + """Subscribe to multicast pushes and register signal handler.""" + self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) + await super().async_added_to_hass() + + async def async_will_remove_from_hass(self) -> None: + """Unsubscribe when removed.""" + self._blind.Remove_callback(self.unique_id) + await super().async_will_remove_from_hass() diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index bca1c1ef1dd33a..977f543ce9830d 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,5 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -9,16 +9,13 @@ EntityCategory, ) 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 -from .const import ATTR_AVAILABLE, DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY +from .entity import MotionCoordinatorEntity from .gateway import device_name ATTR_BATTERY_VOLTAGE = "battery_voltage" -TYPE_BLIND = "blind" -TYPE_GATEWAY = "gateway" async def async_setup_entry( @@ -32,7 +29,7 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id][KEY_COORDINATOR] for blind in motion_gateway.device_list.values(): - entities.append(MotionSignalStrengthSensor(coordinator, blind, TYPE_BLIND)) + entities.append(MotionSignalStrengthSensor(coordinator, blind)) if blind.type == BlindType.TopDownBottomUp: entities.append(MotionTDBUBatterySensor(coordinator, blind, "Bottom")) entities.append(MotionTDBUBatterySensor(coordinator, blind, "Top")) @@ -42,14 +39,12 @@ async def async_setup_entry( # Do not add signal sensor twice for direct WiFi blinds if motion_gateway.device_type not in DEVICE_TYPES_WIFI: - entities.append( - MotionSignalStrengthSensor(coordinator, motion_gateway, TYPE_GATEWAY) - ) + entities.append(MotionSignalStrengthSensor(coordinator, motion_gateway)) async_add_entities(entities) -class MotionBatterySensor(CoordinatorEntity, SensorEntity): +class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Battery Sensor.""" _attr_device_class = SensorDeviceClass.BATTERY @@ -57,24 +52,11 @@ class MotionBatterySensor(CoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - self._blind = blind - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, blind.mac)}) self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - if not self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE]: - return False - - return self.coordinator.data[self._blind.mac][ATTR_AVAILABLE] - @property def native_value(self): """Return the state of the sensor.""" @@ -85,16 +67,6 @@ def extra_state_attributes(self): """Return device specific state attributes.""" return {ATTR_BATTERY_VOLTAGE: self._blind.battery_voltage} - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._blind.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._blind.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() - class MotionTDBUBatterySensor(MotionBatterySensor): """Representation of a Motion Battery Sensor for a Top Down Bottom Up blind.""" @@ -125,7 +97,7 @@ def extra_state_attributes(self): return attributes -class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): +class MotionSignalStrengthSensor(MotionCoordinatorEntity, SensorEntity): """Representation of a Motion Signal Strength Sensor.""" _attr_device_class = SensorDeviceClass.SIGNAL_STRENGTH @@ -133,47 +105,19 @@ class MotionSignalStrengthSensor(CoordinatorEntity, SensorEntity): _attr_native_unit_of_measurement = SIGNAL_STRENGTH_DECIBELS_MILLIWATT _attr_entity_category = EntityCategory.DIAGNOSTIC - def __init__(self, coordinator, device, device_type): + def __init__(self, coordinator, blind): """Initialize the Motion Signal Strength Sensor.""" - super().__init__(coordinator) + super().__init__(coordinator, blind) - if device_type == TYPE_GATEWAY: + if blind.device_type in DEVICE_TYPES_GATEWAY: name = "Motion gateway signal strength" else: - name = f"{device_name(device)} signal strength" + name = f"{device_name(blind)} signal strength" - self._device = device - self._device_type = device_type - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device.mac)}) - self._attr_unique_id = f"{device.mac}-RSSI" + self._attr_unique_id = f"{blind.mac}-RSSI" self._attr_name = name - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.coordinator.data is None: - return False - - gateway_available = self.coordinator.data[KEY_GATEWAY][ATTR_AVAILABLE] - if self._device_type == TYPE_GATEWAY: - return gateway_available - - return ( - gateway_available - and self.coordinator.data[self._device.mac][ATTR_AVAILABLE] - ) - @property def native_value(self): """Return the state of the sensor.""" - return self._device.RSSI - - async def async_added_to_hass(self) -> None: - """Subscribe to multicast pushes.""" - self._device.Register_callback(self.unique_id, self.schedule_update_ha_state) - await super().async_added_to_hass() - - async def async_will_remove_from_hass(self) -> None: - """Unsubscribe when removed.""" - self._device.Remove_callback(self.unique_id) - await super().async_will_remove_from_hass() + return self._blind.RSSI From cf8da2fc8930d540b899a577ca6098e1857b2a57 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sat, 2 Sep 2023 22:13:17 +0200 Subject: [PATCH 025/640] Motion blinds add translations (#99078) --- .../components/motion_blinds/cover.py | 6 ++---- .../components/motion_blinds/entity.py | 2 ++ .../components/motion_blinds/sensor.py | 14 ++----------- .../components/motion_blinds/strings.json | 21 +++++++++++++++++++ 4 files changed, 27 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/motion_blinds/cover.py b/homeassistant/components/motion_blinds/cover.py index 1a4507f10668a2..833d26402025c1 100644 --- a/homeassistant/components/motion_blinds/cover.py +++ b/homeassistant/components/motion_blinds/cover.py @@ -33,7 +33,6 @@ UPDATE_INTERVAL_MOVING_WIFI, ) from .entity import MotionCoordinatorEntity -from .gateway import device_name _LOGGER = logging.getLogger(__name__) @@ -170,6 +169,7 @@ async def async_setup_entry( class MotionPositionDevice(MotionCoordinatorEntity, CoverEntity): """Representation of a Motion Blind Device.""" + _attr_name = None _restore_tilt = False def __init__(self, coordinator, blind, device_class): @@ -184,9 +184,7 @@ def __init__(self, coordinator, blind, device_class): else: self._update_interval_moving = UPDATE_INTERVAL_MOVING - name = device_name(blind) self._attr_device_class = device_class - self._attr_name = name self._attr_unique_id = blind.mac @property @@ -402,7 +400,7 @@ def __init__(self, coordinator, blind, device_class, motor): super().__init__(coordinator, blind, device_class) self._motor = motor self._motor_key = motor[0] - self._attr_name = f"{device_name(blind)} {motor}" + self._attr_translation_key = motor.lower() self._attr_unique_id = f"{blind.mac}-{motor}" if self._motor not in ["Bottom", "Top", "Combined"]: diff --git a/homeassistant/components/motion_blinds/entity.py b/homeassistant/components/motion_blinds/entity.py index d57d7401b473eb..8f3ac05228dae4 100644 --- a/homeassistant/components/motion_blinds/entity.py +++ b/homeassistant/components/motion_blinds/entity.py @@ -22,6 +22,8 @@ class MotionCoordinatorEntity(CoordinatorEntity[DataUpdateCoordinatorMotionBlinds]): """Representation of a Motion Blind entity.""" + _attr_has_entity_name = True + def __init__( self, coordinator: DataUpdateCoordinatorMotionBlinds, diff --git a/homeassistant/components/motion_blinds/sensor.py b/homeassistant/components/motion_blinds/sensor.py index 977f543ce9830d..d8dc25e000666a 100644 --- a/homeassistant/components/motion_blinds/sensor.py +++ b/homeassistant/components/motion_blinds/sensor.py @@ -1,5 +1,5 @@ """Support for Motion Blinds sensors.""" -from motionblinds import DEVICE_TYPES_GATEWAY, DEVICE_TYPES_WIFI, BlindType +from motionblinds import DEVICE_TYPES_WIFI, BlindType from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -13,7 +13,6 @@ from .const import DOMAIN, KEY_COORDINATOR, KEY_GATEWAY from .entity import MotionCoordinatorEntity -from .gateway import device_name ATTR_BATTERY_VOLTAGE = "battery_voltage" @@ -53,8 +52,6 @@ class MotionBatterySensor(MotionCoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Battery Sensor.""" super().__init__(coordinator, blind) - - self._attr_name = f"{device_name(blind)} battery" self._attr_unique_id = f"{blind.mac}-battery" @property @@ -77,7 +74,7 @@ def __init__(self, coordinator, blind, motor): self._motor = motor self._attr_unique_id = f"{blind.mac}-{motor}-battery" - self._attr_name = f"{device_name(blind)} {motor} battery" + self._attr_translation_key = f"{motor.lower()}_battery" @property def native_value(self): @@ -108,14 +105,7 @@ class MotionSignalStrengthSensor(MotionCoordinatorEntity, SensorEntity): def __init__(self, coordinator, blind): """Initialize the Motion Signal Strength Sensor.""" super().__init__(coordinator, blind) - - if blind.device_type in DEVICE_TYPES_GATEWAY: - name = "Motion gateway signal strength" - else: - name = f"{device_name(blind)} signal strength" - self._attr_unique_id = f"{blind.mac}-RSSI" - self._attr_name = name @property def native_value(self): diff --git a/homeassistant/components/motion_blinds/strings.json b/homeassistant/components/motion_blinds/strings.json index 0e0a32bfb2420e..cb9468c3a27d5c 100644 --- a/homeassistant/components/motion_blinds/strings.json +++ b/homeassistant/components/motion_blinds/strings.json @@ -60,5 +60,26 @@ } } } + }, + "entity": { + "cover": { + "top": { + "name": "Top" + }, + "bottom": { + "name": "Bottom" + }, + "combined": { + "name": "Combined" + } + }, + "sensor": { + "top_battery": { + "name": "Top battery" + }, + "bottom_battery": { + "name": "Bottom battery" + } + } } } From f4f78cf00076af95be427cf6f6ddbcc8fefcb259 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 15:25:42 -0500 Subject: [PATCH 026/640] Bump aiohomekit to 3.0.2 (#99514) --- 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 83852f38d523d0..9567ff83cea4c2 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.1"], + "requirements": ["aiohomekit==3.0.2"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 9898a0b8f46f5c..236f6bec4942aa 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d2a3bb2e718e8b..1c31e416a3069c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.1 +aiohomekit==3.0.2 # homeassistant.components.emulated_hue # homeassistant.components.http From bec36d39145a5e0a81b37196bf5f6b34891e9509 Mon Sep 17 00:00:00 2001 From: Richard Kroegel <42204099+rikroe@users.noreply.github.com> Date: Sat, 2 Sep 2023 23:44:28 +0200 Subject: [PATCH 027/640] Add long-term statistics to BMW sensors (#99506) * Add long-term statistics to BMW ConnectedDrive sensors * Add sensor test snapshot --------- Co-authored-by: rikroe --- .../components/bmw_connected_drive/sensor.py | 8 + .../snapshots/test_sensor.ambr | 396 ++++++++++++++++++ .../bmw_connected_drive/test_sensor.py | 18 + 3 files changed, 422 insertions(+) create mode 100644 tests/components/bmw_connected_drive/snapshots/test_sensor.ambr diff --git a/homeassistant/components/bmw_connected_drive/sensor.py b/homeassistant/components/bmw_connected_drive/sensor.py index 8f5b4fb8608a4c..62854badb20e19 100644 --- a/homeassistant/components/bmw_connected_drive/sensor.py +++ b/homeassistant/components/bmw_connected_drive/sensor.py @@ -13,6 +13,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import LENGTH, PERCENTAGE, VOLUME, UnitOfElectricCurrent @@ -94,6 +95,7 @@ def convert_and_round( key_class="fuel_and_battery", unit_type=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, + state_class=SensorStateClass.MEASUREMENT, ), # --- Specific --- "mileage": BMWSensorEntityDescription( @@ -102,6 +104,7 @@ def convert_and_round( icon="mdi:speedometer", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.TOTAL_INCREASING, ), "remaining_range_total": BMWSensorEntityDescription( key="remaining_range_total", @@ -110,6 +113,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_electric": BMWSensorEntityDescription( key="remaining_range_electric", @@ -118,6 +122,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_range_fuel": BMWSensorEntityDescription( key="remaining_range_fuel", @@ -126,6 +131,7 @@ def convert_and_round( icon="mdi:map-marker-distance", unit_type=LENGTH, value=lambda x, hass: convert_and_round(x, hass.config.units.length, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel": BMWSensorEntityDescription( key="remaining_fuel", @@ -134,6 +140,7 @@ def convert_and_round( icon="mdi:gas-station", unit_type=VOLUME, value=lambda x, hass: convert_and_round(x, hass.config.units.volume, 2), + state_class=SensorStateClass.MEASUREMENT, ), "remaining_fuel_percent": BMWSensorEntityDescription( key="remaining_fuel_percent", @@ -141,6 +148,7 @@ def convert_and_round( key_class="fuel_and_battery", icon="mdi:gas-station", unit_type=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, ), } diff --git a/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..d64bdb32597491 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_sensor.ambr @@ -0,0 +1,396 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'iX xDrive50 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'iX xDrive50 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '70', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '340', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'iX xDrive50 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.ix_xdrive50_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i4 eDrive40 Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': '2023-06-22T10:40:00+00:00', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'NOT_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i4 eDrive40 Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '472', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i4_edrive40_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_mileage', + 'last_changed': , + 'last_updated': , + 'state': '1121', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '40', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '629', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'M340i xDrive Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.m340i_xdrive_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': '80', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range total', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_total', + 'last_changed': , + 'last_updated': , + 'state': '279', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Mileage', + 'icon': 'mdi:speedometer', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_mileage', + 'last_changed': , + 'last_updated': , + 'state': '137009', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'timestamp', + 'friendly_name': 'i3 (+ REX) Charging end time', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_end_time', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging status', + 'icon': 'mdi:ev-station', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_status', + 'last_changed': , + 'last_updated': , + 'state': 'WAITING_FOR_CHARGING', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'device_class': 'battery', + 'friendly_name': 'i3 (+ REX) Remaining battery percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_battery_percent', + 'last_changed': , + 'last_updated': , + 'state': '82', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range electric', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_electric', + 'last_changed': , + 'last_updated': , + 'state': '174', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Charging target', + 'icon': 'mdi:battery-charging-high', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_charging_target', + 'last_changed': , + 'last_updated': , + 'state': '100', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel', + 'last_changed': , + 'last_updated': , + 'state': '6', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining range fuel', + 'icon': 'mdi:map-marker-distance', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_range_fuel', + 'last_changed': , + 'last_updated': , + 'state': '105', + }), + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i3 (+ REX) Remaining fuel percent', + 'icon': 'mdi:gas-station', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.i3_rex_remaining_fuel_percent', + 'last_changed': , + 'last_updated': , + 'state': 'unknown', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_sensor.py b/tests/components/bmw_connected_drive/test_sensor.py index 95b1145d9d6ed8..c6cb12cf047022 100644 --- a/tests/components/bmw_connected_drive/test_sensor.py +++ b/tests/components/bmw_connected_drive/test_sensor.py @@ -1,5 +1,8 @@ """Test BMW sensors.""" +from freezegun import freeze_time import pytest +import respx +from syrupy.assertion import SnapshotAssertion from homeassistant.core import HomeAssistant from homeassistant.util.unit_system import ( @@ -11,6 +14,21 @@ from . import setup_mocked_integration +@freeze_time("2023-06-22 10:30:00+00:00") +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test sensor options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all select entities + assert hass.states.async_all("sensor") == snapshot + + @pytest.mark.parametrize( ("entity_id", "unit_system", "value", "unit_of_measurement"), [ From b8f8cd17972b815ca0c1901cc818737e44269e44 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 2 Sep 2023 16:46:53 -0500 Subject: [PATCH 028/640] Reduce overhead to retry config entry setup (#99471) --- homeassistant/config_entries.py | 76 ++++++++++++++++++++++----------- tests/test_config_entries.py | 2 +- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index a3b03407a142d0..7900c6b62a4002 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -148,6 +148,11 @@ def recoverable(self) -> bool: SIGNAL_CONFIG_ENTRY_CHANGED = "config_entry_changed" +NO_RESET_TRIES_STATES = { + ConfigEntryState.SETUP_RETRY, + ConfigEntryState.SETUP_IN_PROGRESS, +} + class ConfigEntryChange(StrEnum): """What was changed in a config entry.""" @@ -220,6 +225,9 @@ class ConfigEntry: "reload_lock", "_tasks", "_background_tasks", + "_integration_for_domain", + "_tries", + "_setup_again_job", ) def __init__( @@ -317,12 +325,15 @@ def __init__( self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() + self._integration_for_domain: loader.Integration | None = None + self._tries = 0 + self._setup_again_job: HassJob | None = None + async def async_setup( self, hass: HomeAssistant, *, integration: loader.Integration | None = None, - tries: int = 0, ) -> None: """Set up an entry.""" current_entry.set(self) @@ -331,6 +342,7 @@ async def async_setup( if integration is None: integration = await loader.async_get_integration(hass, self.domain) + self._integration_for_domain = integration # Only store setup result as state if it was not forwarded. if self.domain == integration.domain: @@ -419,13 +431,13 @@ async def async_setup( result = False except ConfigEntryNotReady as ex: self._async_set_state(hass, ConfigEntryState.SETUP_RETRY, str(ex) or None) - wait_time = 2 ** min(tries, 4) * 5 + ( + wait_time = 2 ** min(self._tries, 4) * 5 + ( randint(RANDOM_MICROSECOND_MIN, RANDOM_MICROSECOND_MAX) / 1000000 ) - tries += 1 + self._tries += 1 message = str(ex) ready_message = f"ready yet: {message}" if message else "ready yet" - if tries == 1: + if self._tries == 1: _LOGGER.warning( ( "Config entry '%s' for %s integration not %s; Retrying in" @@ -447,22 +459,14 @@ async def async_setup( wait_time, ) - async def setup_again(*_: Any) -> None: - """Run setup again.""" - # Check again when we fire in case shutdown - # has started so we do not block shutdown - if hass.is_stopping: - return - self._async_cancel_retry_setup = None - await self.async_setup(hass, integration=integration, tries=tries) - if hass.state == CoreState.running: self._async_cancel_retry_setup = async_call_later( - hass, wait_time, setup_again + hass, wait_time, self._async_get_setup_again_job(hass) ) else: self._async_cancel_retry_setup = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STARTED, setup_again + EVENT_HOMEASSISTANT_STARTED, + functools.partial(self._async_setup_again, hass), ) await self._async_process_on_unload(hass) @@ -483,6 +487,24 @@ async def setup_again(*_: Any) -> None: else: self._async_set_state(hass, ConfigEntryState.SETUP_ERROR, error_reason) + async def _async_setup_again(self, hass: HomeAssistant, *_: Any) -> None: + """Run setup again.""" + # Check again when we fire in case shutdown + # has started so we do not block shutdown + if not hass.is_stopping: + self._async_cancel_retry_setup = None + await self.async_setup(hass) + + @callback + def _async_get_setup_again_job(self, hass: HomeAssistant) -> HassJob: + """Get a job that will call setup again.""" + if not self._setup_again_job: + self._setup_again_job = HassJob( + functools.partial(self._async_setup_again, hass), + cancel_on_shutdown=True, + ) + return self._setup_again_job + async def async_shutdown(self) -> None: """Call when Home Assistant is stopping.""" self.async_cancel_retry_setup() @@ -508,7 +530,7 @@ async def async_unload( if self.state == ConfigEntryState.NOT_LOADED: return True - if integration is None: + if not integration and (integration := self._integration_for_domain) is None: try: integration = await loader.async_get_integration(hass, self.domain) except loader.IntegrationNotFound: @@ -566,14 +588,15 @@ async def async_remove(self, hass: HomeAssistant) -> None: if self.source == SOURCE_IGNORE: return - try: - integration = await loader.async_get_integration(hass, self.domain) - except loader.IntegrationNotFound: - # The integration was likely a custom_component - # that was uninstalled, or an integration - # that has been renamed without removing the config - # entry. - return + if not (integration := self._integration_for_domain): + try: + integration = await loader.async_get_integration(hass, self.domain) + except loader.IntegrationNotFound: + # The integration was likely a custom_component + # that was uninstalled, or an integration + # that has been renamed without removing the config + # entry. + return component = integration.get_component() if not hasattr(component, "async_remove_entry"): @@ -592,6 +615,8 @@ def _async_set_state( self, hass: HomeAssistant, state: ConfigEntryState, reason: str | None ) -> None: """Set the state of the config entry.""" + if state not in NO_RESET_TRIES_STATES: + self._tries = 0 self.state = state self.reason = reason async_dispatcher_send( @@ -617,7 +642,8 @@ async def async_migrate(self, hass: HomeAssistant) -> bool: if self.version == handler.VERSION: return True - integration = await loader.async_get_integration(hass, self.domain) + if not (integration := self._integration_for_domain): + integration = await loader.async_get_integration(hass, self.domain) component = integration.get_component() supports_migrate = hasattr(component, "async_migrate_entry") if not supports_migrate: diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 760c7138c88965..52caa1ae2756af 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -960,7 +960,7 @@ async def test_setup_raise_not_ready( mock_setup_entry.side_effect = None mock_setup_entry.return_value = True - await p_setup(None) + await hass.async_run_hass_job(p_setup, None) assert entry.state is config_entries.ConfigEntryState.LOADED assert entry.reason is None From 4b11a632a133f49f6e521d99d7570b7fd0bcf30a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sat, 2 Sep 2023 23:45:46 +0100 Subject: [PATCH 029/640] Add sensors to private_ble_device (#99515) --- .../components/private_ble_device/__init__.py | 2 +- .../components/private_ble_device/entity.py | 5 +- .../components/private_ble_device/sensor.py | 126 ++++++++++++++++++ .../private_ble_device/strings.json | 10 ++ .../private_ble_device/test_sensor.py | 47 +++++++ 5 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/private_ble_device/sensor.py create mode 100644 tests/components/private_ble_device/test_sensor.py diff --git a/homeassistant/components/private_ble_device/__init__.py b/homeassistant/components/private_ble_device/__init__.py index c4666ccc02fa70..dcb6555bbc9fd2 100644 --- a/homeassistant/components/private_ble_device/__init__.py +++ b/homeassistant/components/private_ble_device/__init__.py @@ -5,7 +5,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/private_ble_device/entity.py b/homeassistant/components/private_ble_device/entity.py index ae632213506609..978313e9671f52 100644 --- a/homeassistant/components/private_ble_device/entity.py +++ b/homeassistant/components/private_ble_device/entity.py @@ -24,7 +24,10 @@ def __init__(self, config_entry: ConfigEntry) -> None: """Set up a new BleScanner entity.""" irk = config_entry.data["irk"] - self._attr_unique_id = irk + if self.translation_key: + self._attr_unique_id = f"{irk}_{self.translation_key}" + else: + self._attr_unique_id = irk self._attr_device_info = DeviceInfo( name=f"Private BLE Device {irk[:6]}", diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py new file mode 100644 index 00000000000000..c2ec4ca39cef5c --- /dev/null +++ b/homeassistant/components/private_ble_device/sensor.py @@ -0,0 +1,126 @@ +"""Support for iBeacon device sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from bluetooth_data_tools import calculate_distance_meters + +from homeassistant.components import bluetooth +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfLength, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .entity import BasePrivateDeviceEntity + + +@dataclass +class PrivateDeviceSensorEntityDescriptionRequired: + """Required domain specific fields for sensor entity.""" + + value_fn: Callable[[bluetooth.BluetoothServiceInfoBleak], str | int | float | None] + + +@dataclass +class PrivateDeviceSensorEntityDescription( + SensorEntityDescription, PrivateDeviceSensorEntityDescriptionRequired +): + """Describes sensor entity.""" + + +SENSOR_DESCRIPTIONS = ( + PrivateDeviceSensorEntityDescription( + key="rssi", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.rssi, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="power", + translation_key="power", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda service_info: service_info.advertisement.tx_power, + state_class=SensorStateClass.MEASUREMENT, + ), + PrivateDeviceSensorEntityDescription( + key="estimated_distance", + translation_key="estimated_distance", + icon="mdi:signal-distance-variant", + native_unit_of_measurement=UnitOfLength.METERS, + value_fn=lambda service_info: service_info.advertisement + and service_info.advertisement.tx_power + and calculate_distance_meters( + service_info.advertisement.tx_power * 10, service_info.advertisement.rssi + ), + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DISTANCE, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up sensors for Private BLE component.""" + async_add_entities( + PrivateBLEDeviceSensor(entry, description) + for description in SENSOR_DESCRIPTIONS + ) + + +class PrivateBLEDeviceSensor(BasePrivateDeviceEntity, SensorEntity): + """A sensor entity.""" + + entity_description: PrivateDeviceSensorEntityDescription + + def __init__( + self, + config_entry: ConfigEntry, + entity_description: PrivateDeviceSensorEntityDescription, + ) -> None: + """Initialize an sensor entity.""" + self.entity_description = entity_description + self._attr_available = False + super().__init__(config_entry) + + @callback + def _async_track_service_info( + self, + service_info: bluetooth.BluetoothServiceInfoBleak, + change: bluetooth.BluetoothChange, + ) -> None: + """Update state.""" + self._attr_available = True + self._last_info = service_info + self.async_write_ha_state() + + @callback + def _async_track_unavailable( + self, service_info: bluetooth.BluetoothServiceInfoBleak + ) -> None: + """Update state.""" + self._attr_available = False + self.async_write_ha_state() + + @property + def native_value(self) -> str | int | float | None: + """Return the state of the sensor.""" + assert self._last_info + return self.entity_description.value_fn(self._last_info) diff --git a/homeassistant/components/private_ble_device/strings.json b/homeassistant/components/private_ble_device/strings.json index c62ea5c4d50f01..279ff38bc9ba10 100644 --- a/homeassistant/components/private_ble_device/strings.json +++ b/homeassistant/components/private_ble_device/strings.json @@ -16,5 +16,15 @@ "abort": { "bluetooth_not_available": "At least one Bluetooth adapter or remote bluetooth proxy must be configured to track Private BLE Devices." } + }, + "entity": { + "sensor": { + "power": { + "name": "Power" + }, + "estimated_distance": { + "name": "Estimated distance" + } + } } } diff --git a/tests/components/private_ble_device/test_sensor.py b/tests/components/private_ble_device/test_sensor.py new file mode 100644 index 00000000000000..820ec2199ad37d --- /dev/null +++ b/tests/components/private_ble_device/test_sensor.py @@ -0,0 +1,47 @@ +"""Tests for sensors.""" + + +from homeassistant.core import HomeAssistant + +from . import MAC_RPA_VALID_1, async_inject_broadcast, async_mock_config_entry + + +async def test_sensor_unavailable( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors are unavailable.""" + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "unavailable" + + +async def test_sensors_already_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we start at home.""" + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + await async_mock_config_entry(hass) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" + + +async def test_sensors_come_home( + hass: HomeAssistant, + enable_bluetooth: None, + entity_registry_enabled_by_default: None, +) -> None: + """Test sensors get value when we receive a broadcast.""" + await async_mock_config_entry(hass) + await async_inject_broadcast(hass, MAC_RPA_VALID_1) + + state = hass.states.get("sensor.private_ble_device_000000_signal_strength") + assert state + assert state.state == "-63" From 6312f345381040d27b855bfeffc59fff060daf07 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 3 Sep 2023 01:07:17 +0200 Subject: [PATCH 030/640] Fix zha test RuntimeWarning (#99519) --- tests/components/zha/test_config_flow.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 77d8a615c722d2..d97a0de0d585e8 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -10,6 +10,7 @@ from zigpy.backups import BackupManager import zigpy.config from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +import zigpy.device from zigpy.exceptions import NetworkNotFormed import zigpy.types @@ -1181,6 +1182,7 @@ async def test_onboarding_auto_formation_new_hardware( ) -> None: """Test auto network formation with new hardware during onboarding.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) + mock_app.get_device = MagicMock(return_value=MagicMock(spec=zigpy.device.Device)) discovery_info = usb.UsbServiceInfo( device="/dev/ttyZIGBEE", pid="AAAA", From 7b1c0c2df20fa23281a05f224a1cd6c0b029d6ca Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sat, 2 Sep 2023 16:19:45 -0700 Subject: [PATCH 031/640] Extend template entities with a script section (#96175) * Extend template entities with a script section This allows making a trigger entity that triggers a few times a day, and allows collecting data from a service resopnse which can be fed into a template entity. The current alternatives are to publish and subscribe to events or to store data in input entities. * Make variables set in actions accessible to templates * Format code --------- Co-authored-by: Erik --- homeassistant/components/script/__init__.py | 3 +- homeassistant/components/template/__init__.py | 19 ++++++-- homeassistant/components/template/config.py | 3 +- homeassistant/components/template/const.py | 1 + .../components/websocket_api/commands.py | 4 +- homeassistant/helpers/script.py | 15 +++++-- tests/components/template/test_sensor.py | 44 +++++++++++++++++++ 7 files changed, 79 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 8530aa3b04c143..13b25a00053171 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -563,7 +563,8 @@ async def _async_start_run( ) coro = self._async_run(variables, context) if wait: - return await coro + script_result = await coro + return script_result.service_response if script_result else None # Caller does not want to wait for called script to finish so let script run in # separate Task. Make a new empty script stack; scripts are allowed to diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index e9ced060491900..c4ba7081f5a76a 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -20,11 +20,12 @@ update_coordinator, ) from homeassistant.helpers.reload import async_reload_integration_platforms +from homeassistant.helpers.script import Script from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS _LOGGER = logging.getLogger(__name__) @@ -133,6 +134,7 @@ def __init__(self, hass, config): self.config = config self._unsub_start: Callable[[], None] | None = None self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None @property def unique_id(self) -> str | None: @@ -170,6 +172,14 @@ async def async_setup(self, hass_config: ConfigType) -> None: async def _attach_triggers(self, start_event=None) -> None: """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + if start_event is not None: self._unsub_start = None @@ -183,8 +193,11 @@ async def _attach_triggers(self, start_event=None) -> None: start_event is not None, ) - @callback - def _handle_triggered(self, run_variables, context=None): + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables self.async_set_updated_data( {"run_variables": run_variables, "context": context} ) diff --git a/homeassistant/components/template/config.py b/homeassistant/components/template/config.py index 2261bde265950c..54c82d88c74bce 100644 --- a/homeassistant/components/template/config.py +++ b/homeassistant/components/template/config.py @@ -22,7 +22,7 @@ select as select_platform, sensor as sensor_platform, ) -from .const import CONF_TRIGGER, DOMAIN +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN PACKAGE_MERGE_HINT = "list" @@ -30,6 +30,7 @@ { vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_TRIGGER): cv.TRIGGER_SCHEMA, + vol.Optional(CONF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(NUMBER_DOMAIN): vol.All( cv.ensure_list, [number_platform.NUMBER_SCHEMA] ), diff --git a/homeassistant/components/template/const.py b/homeassistant/components/template/const.py index 9b371125750f76..6805c0ad81282e 100644 --- a/homeassistant/components/template/const.py +++ b/homeassistant/components/template/const.py @@ -2,6 +2,7 @@ from homeassistant.const import Platform +CONF_ACTION = "action" CONF_AVAILABILITY_TEMPLATE = "availability_template" CONF_ATTRIBUTE_TEMPLATES = "attribute_templates" CONF_TRIGGER = "trigger" diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bbcbfa6ecb8e37..c6564967a394a2 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -713,12 +713,12 @@ async def handle_execute_script( context = connection.context(msg) script_obj = Script(hass, script_config, f"{const.DOMAIN} script", const.DOMAIN) - response = await script_obj.async_run(msg.get("variables"), context=context) + script_result = await script_obj.async_run(msg.get("variables"), context=context) connection.send_result( msg["id"], { "context": context, - "response": response, + "response": script_result.service_response if script_result else None, }, ) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index 4035d55b3258c2..c9d8de23b96cec 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -6,6 +6,7 @@ from contextlib import asynccontextmanager, suppress from contextvars import ContextVar from copy import copy +from dataclasses import dataclass from datetime import datetime, timedelta from functools import partial import itertools @@ -401,7 +402,7 @@ def _step_log(self, default_message, timeout=None): ) self._log("Executing step %s%s", self._script.last_action, _timeout) - async def async_run(self) -> ServiceResponse: + async def async_run(self) -> ScriptRunResult | None: """Run script.""" # Push the script to the script execution stack if (script_stack := script_stack_cv.get()) is None: @@ -443,7 +444,7 @@ async def async_run(self) -> ServiceResponse: script_stack.pop() self._finish() - return response + return ScriptRunResult(response, self._variables) async def _async_step(self, log_exceptions): continue_on_error = self._action.get(CONF_CONTINUE_ON_ERROR, False) @@ -1189,6 +1190,14 @@ class _IfData(TypedDict): if_else: Script | None +@dataclass +class ScriptRunResult: + """Container with the result of a script run.""" + + service_response: ServiceResponse + variables: dict + + class Script: """Representation of a script.""" @@ -1480,7 +1489,7 @@ async def async_run( run_variables: _VarsType | None = None, context: Context | None = None, started_action: Callable[..., Any] | None = None, - ) -> ServiceResponse: + ) -> ScriptRunResult | None: """Run script.""" if context is None: self._log( diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 47e307bc6aacd9..cf9f3724020bfe 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1582,3 +1582,47 @@ async def test_trigger_entity_restore_state( assert state.attributes["entity_picture"] == "/local/dogs.png" assert state.attributes["plus_one"] == 3 assert state.attributes["another"] == 1 + + +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "unique_id": "listening-test-event", + "trigger": {"platform": "event", "event_type": "test_event"}, + "action": [ + { + "variables": { + "my_variable": "{{ trigger.event.data.beer + 1 }}" + }, + }, + ], + "sensor": [ + { + "name": "Hello Name", + "state": "{{ my_variable + 1 }}", + } + ], + }, + ], + }, + ], +) +async def test_trigger_action( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity with an action works.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + context = Context() + hass.bus.async_fire("test_event", {"beer": 1}, context=context) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "3" + assert state.context is context From 61dc217d92055a52ea75094c37621ed3a725f7e3 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Sun, 3 Sep 2023 10:25:00 +0200 Subject: [PATCH 032/640] Bump gardena_bluetooth to 1.4.0 (#99530) --- homeassistant/components/gardena_bluetooth/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 5d1c1888586017..3e07eb1ad425f6 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.3.0"] + "requirements": ["gardena_bluetooth==1.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 236f6bec4942aa..7945efdf93cad4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -837,7 +837,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1c31e416a3069c..823b5d36dece4a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -656,7 +656,7 @@ fritzconnection[qr]==1.12.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.3.0 +gardena_bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 From 1183bd159e898ab6cd09f6daf93cbebd7098b571 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 04:16:26 -0500 Subject: [PATCH 033/640] Bump zeroconf to 0.93.1 (#99516) * Bump zeroconf to 0.92.0 changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.91.1...0.92.0 * drop unused argument * Update tests/components/thread/test_diagnostics.py * lint * again * bump again since actions failed to release the wheels --- .../components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/thread/test_diagnostics.py | 44 +++++++++---------- 5 files changed, 25 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index 26577bd0bbe491..718f3047a073b7 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.91.1"] + "requirements": ["zeroconf==0.93.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8069d5c0e70986..bd9125c59fef36 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.91.1 +zeroconf==0.93.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 7945efdf93cad4..32d04870543758 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.91.1 +zeroconf==0.93.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 823b5d36dece4a..12fda706d08593 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.91.1 +zeroconf==0.93.1 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/tests/components/thread/test_diagnostics.py b/tests/components/thread/test_diagnostics.py index 94ca437371587d..15ab07503162af 100644 --- a/tests/components/thread/test_diagnostics.py +++ b/tests/components/thread/test_diagnostics.py @@ -1,7 +1,6 @@ """Test the thread websocket API.""" import dataclasses -import time from unittest.mock import Mock, patch import pytest @@ -191,50 +190,49 @@ async def test_diagnostics( """Test diagnostics for thread routers.""" cache = mock_async_zeroconf.zeroconf.cache = DNSCache() - now = time.monotonic() * 1000 cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_1.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_1.dns_service(created=now), - TEST_ZEROCONF_RECORD_1.dns_text(created=now), - TEST_ZEROCONF_RECORD_1.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_1.dns_addresses(), + TEST_ZEROCONF_RECORD_1.dns_service(), + TEST_ZEROCONF_RECORD_1.dns_text(), + TEST_ZEROCONF_RECORD_1.dns_pointer(), ] ) cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_2.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_2.dns_service(created=now), - TEST_ZEROCONF_RECORD_2.dns_text(created=now), - TEST_ZEROCONF_RECORD_2.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_2.dns_addresses(), + TEST_ZEROCONF_RECORD_2.dns_service(), + TEST_ZEROCONF_RECORD_2.dns_text(), + TEST_ZEROCONF_RECORD_2.dns_pointer(), ] ) # Test for invalid cache - cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer(created=now)]) + cache.async_add_records([TEST_ZEROCONF_RECORD_3.dns_pointer()]) # Test for invalid record cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_4.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_4.dns_service(created=now), - TEST_ZEROCONF_RECORD_4.dns_text(created=now), - TEST_ZEROCONF_RECORD_4.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_4.dns_addresses(), + TEST_ZEROCONF_RECORD_4.dns_service(), + TEST_ZEROCONF_RECORD_4.dns_text(), + TEST_ZEROCONF_RECORD_4.dns_pointer(), ] ) # Test for record without xa cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_5.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_5.dns_service(created=now), - TEST_ZEROCONF_RECORD_5.dns_text(created=now), - TEST_ZEROCONF_RECORD_5.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_5.dns_addresses(), + TEST_ZEROCONF_RECORD_5.dns_service(), + TEST_ZEROCONF_RECORD_5.dns_text(), + TEST_ZEROCONF_RECORD_5.dns_pointer(), ] ) # Test for record without xp cache.async_add_records( [ - *TEST_ZEROCONF_RECORD_6.dns_addresses(created=now), - TEST_ZEROCONF_RECORD_6.dns_service(created=now), - TEST_ZEROCONF_RECORD_6.dns_text(created=now), - TEST_ZEROCONF_RECORD_6.dns_pointer(created=now), + *TEST_ZEROCONF_RECORD_6.dns_addresses(), + TEST_ZEROCONF_RECORD_6.dns_service(), + TEST_ZEROCONF_RECORD_6.dns_text(), + TEST_ZEROCONF_RECORD_6.dns_pointer(), ] ) assert await async_setup_component(hass, DOMAIN, {}) From 6414248bee1ab9e9491751b3dd0a6081db8a46a0 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 3 Sep 2023 13:04:01 +0200 Subject: [PATCH 034/640] Update pytest warning filter (#99521) --- pyproject.toml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 8f5b5c788fa035..e535e7bbc7bc7f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -449,6 +449,12 @@ 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/gwww/elkm1/pull/71 - v2.2.5 + "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:elkm1_lib.util", + # https://github.com/poljar/matrix-nio/pull/438 - v0.21.2 + "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", + # https://github.com/poljar/matrix-nio/pull/439 - v0.21.2 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", # 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 @@ -465,6 +471,10 @@ filterwarnings = [ "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", # -- fixed, waiting for release / update + # 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/gurumitts/pylutron-caseta/pull/143 - >0.18.1 "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 @@ -474,6 +484,11 @@ filterwarnings = [ # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 "ignore:The module pyatmo.* is deprecated:DeprecationWarning:pyatmo", + # -- other + # 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", + # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:agent.a", From 1cd0cb4537a420a6572583f6eb163318ddba340a Mon Sep 17 00:00:00 2001 From: Jc2k Date: Sun, 3 Sep 2023 13:39:20 +0100 Subject: [PATCH 035/640] Add suggest_display_precision to private_ble_device (#99529) --- homeassistant/components/private_ble_device/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/private_ble_device/sensor.py b/homeassistant/components/private_ble_device/sensor.py index c2ec4ca39cef5c..e2f5efb669953d 100644 --- a/homeassistant/components/private_ble_device/sensor.py +++ b/homeassistant/components/private_ble_device/sensor.py @@ -71,6 +71,7 @@ class PrivateDeviceSensorEntityDescription( ), state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.DISTANCE, + suggested_display_precision=1, ), ) From 5f487b5d8582670021479f70922d9427302b10a3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:19:06 -0500 Subject: [PATCH 036/640] Refactor async_call_at and async_call_later event helpers to avoid creating closures (#99469) --- homeassistant/helpers/event.py | 40 +++++++++------------------------- 1 file changed, 10 insertions(+), 30 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 62a3b91991d9d1..b8831d38d863b0 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1432,6 +1432,13 @@ def unsub_point_in_time_listener() -> None: track_point_in_utc_time = threaded_listener_factory(async_track_point_in_utc_time) +def _run_async_call_action( + hass: HomeAssistant, job: HassJob[[datetime], Coroutine[Any, Any, None] | None] +) -> None: + """Run action.""" + hass.async_run_hass_job(job, time_tracker_utcnow()) + + @callback @bind_hass def async_call_at( @@ -1441,26 +1448,12 @@ def async_call_at( loop_time: float, ) -> CALLBACK_TYPE: """Add a listener that is called at .""" - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_at {loop_time}") ) - cancel_callback = hass.loop.call_at(loop_time, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + return hass.loop.call_at(loop_time, _run_async_call_action, hass, job).cancel @callback @@ -1474,26 +1467,13 @@ def async_call_later( """Add a listener that is called in .""" if isinstance(delay, timedelta): delay = delay.total_seconds() - - @callback - def run_action(job: HassJob[[datetime], Coroutine[Any, Any, None] | None]) -> None: - """Call the action.""" - hass.async_run_hass_job(job, time_tracker_utcnow()) - job = ( action if isinstance(action, HassJob) else HassJob(action, f"call_later {delay}") ) - cancel_callback = hass.loop.call_at(hass.loop.time() + delay, run_action, job) - - @callback - def unsub_call_later_listener() -> None: - """Cancel the call_later.""" - assert cancel_callback is not None - cancel_callback.cancel() - - return unsub_call_later_listener + loop = hass.loop + return loop.call_at(loop.time() + delay, _run_async_call_action, hass, job).cancel call_later = threaded_listener_factory(async_call_later) From 00893bbf14ac2b2e9c1dfc739409fff95d890604 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:22:03 -0500 Subject: [PATCH 037/640] Bump bleak to 0.21.0 (#99520) Co-authored-by: Martin Hjelmare --- .../components/bluetooth/manifest.json | 2 +- homeassistant/components/bluetooth/wrappers.py | 6 ++++-- .../components/esphome/bluetooth/client.py | 18 +++++++++++------- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 19 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 54c8a52e24b551..8dd87b8361de7d 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.20.2", + "bleak==0.21.0", "bleak-retry-connector==3.1.1", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", diff --git a/homeassistant/components/bluetooth/wrappers.py b/homeassistant/components/bluetooth/wrappers.py index 3a0abc855b596a..97f253f882525c 100644 --- a/homeassistant/components/bluetooth/wrappers.py +++ b/homeassistant/components/bluetooth/wrappers.py @@ -120,15 +120,17 @@ def discovered_devices(self) -> list[BLEDevice]: def register_detection_callback( self, callback: AdvertisementDataCallback | None - ) -> None: + ) -> Callable[[], None]: """Register a detection callback. The callback is called when a device is discovered or has a property changed. - This method takes the callback and registers it with the long running sscanner. + This method takes the callback and registers it with the long running scanner. """ self._advertisement_data_callback = callback self._setup_detection_callback() + assert self._detection_cancel is not None + return self._detection_cancel def _setup_detection_callback(self) -> None: """Set up the detection callback.""" diff --git a/homeassistant/components/esphome/bluetooth/client.py b/homeassistant/components/esphome/bluetooth/client.py index ad43ca5df7d016..411a5b989a3a6e 100644 --- a/homeassistant/components/esphome/bluetooth/client.py +++ b/homeassistant/components/esphome/bluetooth/client.py @@ -7,9 +7,15 @@ from dataclasses import dataclass, field from functools import partial import logging +import sys from typing import Any, TypeVar, cast import uuid +if sys.version_info < (3, 12): + from typing_extensions import Buffer +else: + from collections.abc import Buffer + from aioesphomeapi import ( ESP_CONNECTION_ERROR_DESCRIPTION, ESPHOME_GATT_ERRORS, @@ -620,14 +626,14 @@ async def read_gatt_descriptor(self, handle: int, **kwargs: Any) -> bytearray: @api_error_as_bleak_error async def write_gatt_char( self, - char_specifier: BleakGATTCharacteristic | int | str | uuid.UUID, - data: bytes | bytearray | memoryview, + characteristic: BleakGATTCharacteristic | int | str | uuid.UUID, + data: Buffer, response: bool = False, ) -> None: """Perform a write operation of the specified GATT characteristic. Args: - char_specifier (BleakGATTCharacteristic, int, str or UUID): + characteristic (BleakGATTCharacteristic, int, str or UUID): The characteristic to write to, specified by either integer handle, UUID or directly by the BleakGATTCharacteristic object representing it. @@ -635,16 +641,14 @@ async def write_gatt_char( response (bool): If write-with-response operation should be done. Defaults to `False`. """ - characteristic = self._resolve_characteristic(char_specifier) + characteristic = self._resolve_characteristic(characteristic) await self._client.bluetooth_gatt_write( self._address_as_int, characteristic.handle, bytes(data), response ) @verify_connected @api_error_as_bleak_error - async def write_gatt_descriptor( - self, handle: int, data: bytes | bytearray | memoryview - ) -> None: + async def write_gatt_descriptor(self, handle: int, data: Buffer) -> None: """Perform a write operation on the specified GATT descriptor. Args: diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd9125c59fef36..8077c6e5712cfa 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.1 -bleak==0.20.2 +bleak==0.21.0 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 bluetooth-data-tools==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 32d04870543758..8ae3ed3db0a664 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 12fda706d08593..eaa6a84ad89aa2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ bimmer-connected==0.14.0 bleak-retry-connector==3.1.1 # homeassistant.components.bluetooth -bleak==0.20.2 +bleak==0.21.0 # homeassistant.components.blebox blebox-uniapi==2.1.4 From 31d1752c74838fdf4db72276fe81572b3510419f Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 4 Sep 2023 01:53:23 +1200 Subject: [PATCH 038/640] Bugfix: Electric Kiwi reduce interval so oauth doesn't expire (#99489) decrease interval time as EK have broken/changed their oauth again --- homeassistant/components/electric_kiwi/coordinator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/electric_kiwi/coordinator.py b/homeassistant/components/electric_kiwi/coordinator.py index 49611f9febd1f7..b084f4656d50bc 100644 --- a/homeassistant/components/electric_kiwi/coordinator.py +++ b/homeassistant/components/electric_kiwi/coordinator.py @@ -14,7 +14,7 @@ _LOGGER = logging.getLogger(__name__) -HOP_SCAN_INTERVAL = timedelta(hours=2) +HOP_SCAN_INTERVAL = timedelta(minutes=20) class ElectricKiwiHOPDataCoordinator(DataUpdateCoordinator[Hop]): From 9bba501057dcbe61810036d9c380f6a7d701c893 Mon Sep 17 00:00:00 2001 From: Yuxin Wang Date: Sun, 3 Sep 2023 09:54:00 -0400 Subject: [PATCH 039/640] Handle gracefully when unloading apcupsd config entries (#99513) --- homeassistant/components/apcupsd/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/apcupsd/__init__.py b/homeassistant/components/apcupsd/__init__.py index 164a908e8340e9..8d7c6b2f46d78c 100644 --- a/homeassistant/components/apcupsd/__init__.py +++ b/homeassistant/components/apcupsd/__init__.py @@ -48,7 +48,8 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b 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) - hass.data[DOMAIN].pop(entry.entry_id) + if unload_ok and DOMAIN in hass.data: + hass.data[DOMAIN].pop(entry.entry_id) return unload_ok From 8843a445c9fd4d5eed1b8c1fc3134634f7dc0714 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 08:59:15 -0500 Subject: [PATCH 040/640] Reduce Bluetooth coordinator/processor overhead (#99526) --- homeassistant/components/bluetooth/active_update_coordinator.py | 2 +- homeassistant/components/bluetooth/active_update_processor.py | 2 +- homeassistant/components/bluetooth/passive_update_processor.py | 1 - 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bluetooth/active_update_coordinator.py b/homeassistant/components/bluetooth/active_update_coordinator.py index 5fa05b87cc8701..cdf51d34978423 100644 --- a/homeassistant/components/bluetooth/active_update_coordinator.py +++ b/homeassistant/components/bluetooth/active_update_coordinator.py @@ -110,7 +110,7 @@ def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/active_update_processor.py b/homeassistant/components/bluetooth/active_update_processor.py index 8e38191c820ac0..a3f5e20a9e97e3 100644 --- a/homeassistant/components/bluetooth/active_update_processor.py +++ b/homeassistant/components/bluetooth/active_update_processor.py @@ -103,7 +103,7 @@ def needs_poll(self, service_info: BluetoothServiceInfoBleak) -> bool: return False poll_age: float | None = None if self._last_poll: - poll_age = monotonic_time_coarse() - self._last_poll + poll_age = service_info.time - self._last_poll return self._needs_poll_method(service_info, poll_age) async def _async_poll_data( diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 20b992d06d6ed4..6d0621fa4f6074 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,7 +341,6 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" - super()._async_handle_bluetooth_event(service_info, change) if self.hass.is_stopping: return From 8e22041ee96c65137034a78c36fc748f8c389fb6 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 3 Sep 2023 17:12:37 +0300 Subject: [PATCH 041/640] Change calculation methods to a fixed list (#99535) --- .../islamic_prayer_times/config_flow.py | 13 ++++++++++- .../components/islamic_prayer_times/const.py | 21 +++++++++++++++--- .../islamic_prayer_times/strings.json | 22 +++++++++++++++++++ 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index d0d314fe67d223..597d67c19f4db4 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -8,6 +8,11 @@ from homeassistant import config_entries from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME @@ -58,7 +63,13 @@ async def async_step_init( default=self.config_entry.options.get( CONF_CALC_METHOD, DEFAULT_CALC_METHOD ), - ): vol.In(CALC_METHODS) + ): SelectSelector( + SelectSelectorConfig( + options=CALC_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_CALC_METHOD, + ) + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 2a73a33bef8029..67fac6c92610f4 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -1,12 +1,27 @@ """Constants for the Islamic Prayer component.""" from typing import Final -from prayer_times_calculator import PrayerTimesCalculator - DOMAIN: Final = "islamic_prayer_times" NAME: Final = "Islamic Prayer Times" CONF_CALC_METHOD: Final = "calculation_method" -CALC_METHODS: list[str] = list(PrayerTimesCalculator.CALCULATION_METHODS) +CALC_METHODS: Final = [ + "jafari", + "karachi", + "isna", + "mwl", + "makkah", + "egypt", + "tehran", + "gulf", + "kuwait", + "qatar", + "singapore", + "france", + "turkey", + "russia", + "moonsighting", + "custom", +] DEFAULT_CALC_METHOD: Final = "isna" diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index 7c09cc605bdf01..d02b26ec5332bc 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -20,6 +20,28 @@ } } }, + "selector": { + "calculation_method": { + "options": { + "jafari": "Shia Ithna-Ansari", + "karachi": "University of Islamic Sciences, Karachi", + "isna": "Islamic Society of North America", + "mwl": "Muslim World League", + "makkah": "Umm Al-Qura University, Makkah", + "egypt": "Egyptian General Authority of Survey", + "tehran": "Institute of Geophysics, University of Tehran", + "gulf": "Gulf Region", + "kuwait": "Kuwait", + "qatar": "Qatar", + "singapore": "Majlis Ugama Islam Singapura, Singapore", + "france": "Union Organization islamic de France", + "turkey": "Diyanet İşleri Başkanlığı, Turkey", + "russia": "Spiritual Administration of Muslims of Russia", + "moonsighting": "Moonsighting Committee Worldwide", + "custom": "Custom" + } + } + }, "entity": { "sensor": { "fajr": { From d063650fec79eff739631f0f0cc3e64b9577ce24 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:13:21 -0500 Subject: [PATCH 042/640] Bump bleak-retry-connector to 3.1.2 (#99540) --- 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 8dd87b8361de7d..e1a5ee41324a8c 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,7 +15,7 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.0", - "bleak-retry-connector==3.1.1", + "bleak-retry-connector==3.1.2", "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8077c6e5712cfa..c1b5f5a947f5cb 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==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 bleak==0.21.0 bluetooth-adapters==0.16.0 bluetooth-auto-recovery==1.2.1 diff --git a/requirements_all.txt b/requirements_all.txt index 8ae3ed3db0a664..05c56ef95e9846 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,7 +519,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth bleak==0.21.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaa6a84ad89aa2..2b132a606c1f1e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ bellows==0.36.1 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.1 +bleak-retry-connector==3.1.2 # homeassistant.components.bluetooth bleak==0.21.0 From b752419f25650f8a0f3aded6b2e883f154a95922 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:13:34 -0500 Subject: [PATCH 043/640] Bump aioesphomeapi to 16.0.4 (#99541) --- 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 bfb33c7b7d066d..32d915f8b769f3 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==16.0.3", + "aioesphomeapi==16.0.4", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 05c56ef95e9846..453eeac3f6ead0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.4 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2b132a606c1f1e..3a2c66c337e6e0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -213,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.3 +aioesphomeapi==16.0.4 # homeassistant.components.flo aioflo==2021.11.0 From 186e796e25c1f90eafe69399c4e93b75c5cee973 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 09:30:39 -0500 Subject: [PATCH 044/640] Speed up fetching states by domain (#99467) --- homeassistant/core.py | 42 +++++++++++++++++++++++------------------- tests/test_core.py | 1 + 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 89269ae9158cf9..bd5967807593b1 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1261,7 +1261,7 @@ def __init__( "State max length is 255 characters." ) - self.entity_id = entity_id.lower() + self.entity_id = entity_id self.state = state self.attributes = ReadOnlyDict(attributes or {}) self.last_updated = last_updated or dt_util.utcnow() @@ -1412,11 +1412,12 @@ def __repr__(self) -> str: class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_reservations", "_bus", "_loop") + __slots__ = ("_states", "_domain_index", "_reservations", "_bus", "_loop") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" self._states: dict[str, State] = {} + self._domain_index: dict[str, dict[str, State]] = {} self._reservations: set[str] = set() self._bus = bus self._loop = loop @@ -1440,13 +1441,13 @@ def async_entity_ids( return list(self._states) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._domain_index.get(domain_filter.lower(), ())) - return [ - state.entity_id - for state in self._states.values() - if state.domain in domain_filter - ] + states: list[str] = [] + for domain in domain_filter: + if domain_index := self._domain_index.get(domain): + states.extend(domain_index) + return states @callback def async_entity_ids_count( @@ -1460,11 +1461,9 @@ def async_entity_ids_count( return len(self._states) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return len(self._domain_index.get(domain_filter.lower(), ())) - return len( - [None for state in self._states.values() if state.domain in domain_filter] - ) + return sum(len(self._domain_index.get(domain, ())) for domain in domain_filter) def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: """Create a list of all states.""" @@ -1484,11 +1483,13 @@ def async_all( return list(self._states.values()) if isinstance(domain_filter, str): - domain_filter = (domain_filter.lower(),) + return list(self._domain_index.get(domain_filter.lower(), {}).values()) - return [ - state for state in self._states.values() if state.domain in domain_filter - ] + states: list[State] = [] + for domain in domain_filter: + if domain_index := self._domain_index.get(domain): + states.extend(domain_index.values()) + return states def get(self, entity_id: str) -> State | None: """Retrieve state of entity_id or None if not found. @@ -1524,13 +1525,12 @@ def async_remove(self, entity_id: str, context: Context | None = None) -> bool: """ entity_id = entity_id.lower() old_state = self._states.pop(entity_id, None) - - if entity_id in self._reservations: - self._reservations.remove(entity_id) + self._reservations.discard(entity_id) if old_state is None: return False + self._domain_index[old_state.domain].pop(entity_id) old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, @@ -1652,6 +1652,10 @@ def async_set( if old_state is not None: old_state.expire() self._states[entity_id] = state + if not (domain_index := self._domain_index.get(state.domain)): + domain_index = {} + self._domain_index[state.domain] = domain_index + domain_index[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, diff --git a/tests/test_core.py b/tests/test_core.py index 4f7916e757b4d5..f4a80468050df6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -1938,6 +1938,7 @@ async def test_async_entity_ids_count(hass: HomeAssistant) -> None: assert hass.states.async_entity_ids_count() == 5 assert hass.states.async_entity_ids_count("light") == 3 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 4 async def test_hassjob_forbid_coroutine() -> None: From c297eecb683884ce647750543ebf699e51fead61 Mon Sep 17 00:00:00 2001 From: Mike O'Driscoll Date: Sun, 3 Sep 2023 11:08:17 -0400 Subject: [PATCH 045/640] Fix recollect_waste month time boundary issue (#99429) --- .../components/recollect_waste/__init__.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recollect_waste/__init__.py b/homeassistant/components/recollect_waste/__init__.py index 21cf574d548b69..076067312eb63b 100644 --- a/homeassistant/components/recollect_waste/__init__.py +++ b/homeassistant/components/recollect_waste/__init__.py @@ -1,7 +1,7 @@ """The ReCollect Waste integration.""" from __future__ import annotations -from datetime import timedelta +from datetime import date, timedelta from typing import Any from aiorecollect.client import Client, PickupEvent @@ -31,7 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_get_pickup_events() -> list[PickupEvent]: """Get the next pickup.""" try: - return await client.async_get_pickup_events() + # Retrieve today through to 35 days in the future, to get + # coverage across a full two months boundary so that no + # upcoming pickups are missed. The api.recollect.net base API + # call returns only the current month when no dates are passed. + # This ensures that data about when the next pickup is will be + # returned when the next pickup is the first day of the next month. + # Ex: Today is August 31st, tomorrow is a pickup on September 1st. + today = date.today() + return await client.async_get_pickup_events( + start_date=today, + end_date=today + timedelta(days=35), + ) except RecollectError as err: raise UpdateFailed( f"Error while requesting data from ReCollect: {err}" From c94d4f501b30d1d015e1305d88c8a69f5fa8275e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 17:13:49 +0200 Subject: [PATCH 046/640] Read modbus data before scan_interval (#99243) Read before scan_interval. --- homeassistant/components/modbus/base_platform.py | 4 +--- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_sensor.py | 5 +---- 3 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 7c3fcd78b05f9e..e85857b5fb4a82 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -30,7 +30,6 @@ from homeassistant.helpers.restore_state import RestoreEntity from .const import ( - ACTIVE_SCAN_INTERVAL, CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, @@ -115,8 +114,7 @@ async def async_update(self, now: datetime | None = None) -> None: def async_run(self) -> None: """Remote start entity.""" self.async_hold(update=False) - if self._scan_interval == 0 or self._scan_interval > ACTIVE_SCAN_INTERVAL: - self._cancel_call = async_call_later(self.hass, 1, self.async_update) + self._cancel_call = async_call_later(self.hass, 1, self.async_update) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 23d3ee522bb6e7..d4c7dfa5e10e77 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -149,7 +149,7 @@ async def mock_do_cycle_fixture( mock_pymodbus_return, ) -> FrozenDateTimeFactory: """Trigger update call with time_changed event.""" - freezer.tick(timedelta(seconds=90)) + freezer.tick(timedelta(seconds=1)) async_fire_time_changed(hass) await hass.async_block_till_done() return freezer diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index f72371ed42e81a..12d5d558408609 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -267,7 +267,6 @@ async def test_config_wrong_struct_sensor( { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -710,7 +709,6 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, - CONF_SCAN_INTERVAL: 1, }, ], }, @@ -935,7 +933,7 @@ async def test_lazy_error_sensor( hass.states.async_set(ENTITY_ID, 17) await hass.async_block_till_done() assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) + await do_next_cycle(hass, mock_do_cycle, 5) assert hass.states.get(ENTITY_ID).state == start_expect await do_next_cycle(hass, mock_do_cycle, 11) assert hass.states.get(ENTITY_ID).state == end_expect @@ -1003,7 +1001,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No { CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 201, - CONF_SCAN_INTERVAL: 1, }, ], }, From c938b9e7a308de3198e8016a582c0f7e5061804a Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Sep 2023 08:36:20 -0700 Subject: [PATCH 047/640] Rename nest test_sensor_sdm.py to test_sensor.py (#99512) --- tests/components/nest/{test_sensor_sdm.py => test_sensor.py} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/components/nest/{test_sensor_sdm.py => test_sensor.py} (100%) diff --git a/tests/components/nest/test_sensor_sdm.py b/tests/components/nest/test_sensor.py similarity index 100% rename from tests/components/nest/test_sensor_sdm.py rename to tests/components/nest/test_sensor.py From d19f617c2535a421a43895457cda90900e7cfa07 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 17:48:25 +0200 Subject: [PATCH 048/640] Modbus switch, allow restore "unknown" (#99533) --- homeassistant/components/modbus/base_platform.py | 6 +++++- tests/components/modbus/test_switch.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index e85857b5fb4a82..b71f8c2021584c 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -21,6 +21,7 @@ CONF_SLAVE, CONF_STRUCTURE, CONF_UNIQUE_ID, + STATE_OFF, STATE_ON, ) from homeassistant.core import callback @@ -309,7 +310,10 @@ async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" await self.async_base_added_to_hass() if state := await self.async_get_last_state(): - self._attr_is_on = state.state == STATE_ON + if state.state == STATE_ON: + self._attr_is_on = True + elif state.state == STATE_OFF: + self._attr_is_on = False async def async_turn(self, command: int) -> None: """Evaluate switch result.""" diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index dce4588d606859..7a79e19869aa5b 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -250,7 +250,7 @@ async def test_lazy_error_switch( @pytest.mark.parametrize( "mock_test_state", - [(State(ENTITY_ID, STATE_ON),)], + [(State(ENTITY_ID, STATE_ON),), (State(ENTITY_ID, STATE_OFF),)], indirect=True, ) @pytest.mark.parametrize( From ca442420952d0ac3d4f00019e7a173c6e0f7e189 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Sun, 3 Sep 2023 20:22:59 +0300 Subject: [PATCH 049/640] Allow glances entries with same IP but different ports (#99536) --- homeassistant/components/glances/config_flow.py | 7 +++++-- tests/components/glances/test_config_flow.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/config_flow.py b/homeassistant/components/glances/config_flow.py index 04e133248a6399..58b81bc088e310 100644 --- a/homeassistant/components/glances/config_flow.py +++ b/homeassistant/components/glances/config_flow.py @@ -61,11 +61,14 @@ async def async_step_user( """Handle the initial step.""" errors = {} if user_input is not None: - self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + self._async_abort_entries_match( + {CONF_HOST: user_input[CONF_HOST], CONF_PORT: user_input[CONF_PORT]} + ) try: await validate_input(self.hass, user_input) return self.async_create_entry( - title=user_input[CONF_HOST], data=user_input + title=f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}", + data=user_input, ) except CannotConnect: errors["base"] = "cannot_connect" diff --git a/tests/components/glances/test_config_flow.py b/tests/components/glances/test_config_flow.py index 187e319fe08a96..d4d25d8b86f6ff 100644 --- a/tests/components/glances/test_config_flow.py +++ b/tests/components/glances/test_config_flow.py @@ -35,7 +35,7 @@ async def test_form(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "0.0.0.0" + assert result["title"] == "0.0.0.0:61208" assert result["data"] == MOCK_USER_INPUT From 7931d74938060be9f5cc7b6d335d7b03394939f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 12:39:49 -0500 Subject: [PATCH 050/640] Make bond BPUP callback a HassJob (#99470) --- homeassistant/components/bond/entity.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/bond/entity.py b/homeassistant/components/bond/entity.py index 3b3ace989508c7..03a5f444579c7c 100644 --- a/homeassistant/components/bond/entity.py +++ b/homeassistant/components/bond/entity.py @@ -17,7 +17,7 @@ ATTR_SW_VERSION, ATTR_VIA_DEVICE, ) -from homeassistant.core import CALLBACK_TYPE, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_call_later @@ -68,6 +68,9 @@ def __init__( self._attr_assumed_state = self._hub.is_bridge and not self._device.trust_state self._apply_state() self._bpup_polling_fallback: CALLBACK_TYPE | None = None + self._async_update_if_bpup_not_alive_job = HassJob( + self._async_update_if_bpup_not_alive + ) @property def device_info(self) -> DeviceInfo: @@ -185,7 +188,7 @@ def _async_schedule_bpup_alive_or_poll(self) -> None: self._bpup_polling_fallback = async_call_later( self.hass, _BPUP_ALIVE_SCAN_INTERVAL if alive else _FALLBACK_SCAN_INTERVAL, - self._async_update_if_bpup_not_alive, + self._async_update_if_bpup_not_alive_job, ) async def async_will_remove_from_hass(self) -> None: From f85a3861f2c30bbebe8bbaa34dda8b67843424f5 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sun, 3 Sep 2023 21:04:58 +0200 Subject: [PATCH 051/640] Make validator for modbus table controlled (#99092) --- homeassistant/components/modbus/__init__.py | 1 - homeassistant/components/modbus/validators.py | 155 ++++++++++-------- tests/components/modbus/test_sensor.py | 16 +- 3 files changed, 88 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index cb36661d711169..b4258d47d5e26e 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -171,7 +171,6 @@ DataType.FLOAT32, DataType.FLOAT64, DataType.STRING, - DataType.STRING, DataType.CUSTOM, ] ), diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index f5f88ea5f59be7..aec781b065ecc1 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -30,6 +30,8 @@ CONF_SWAP, CONF_SWAP_BYTE, CONF_SWAP_NONE, + CONF_SWAP_WORD, + CONF_SWAP_WORD_BYTE, CONF_WRITE_TYPE, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, @@ -40,97 +42,108 @@ _LOGGER = logging.getLogger(__name__) -ENTRY = namedtuple("ENTRY", ["struct_id", "register_count"]) +ENTRY = namedtuple( + "ENTRY", + [ + "struct_id", + "register_count", + "validate_parm", + ], +) +PARM_IS_LEGAL = namedtuple( + "PARM_IS_LEGAL", + [ + "count", + "structure", + "slave_count", + "swap_byte", + "swap_word", + ], +) +# PARM_IS_LEGAL defines if the keywords: +# count: .. +# structure: .. +# swap: byte +# swap: word +# swap: word_byte (identical to swap: word) +# are legal to use. +# These keywords are only legal with some datatype: ... +# As expressed in DEFAULT_STRUCT_FORMAT + DEFAULT_STRUCT_FORMAT = { - DataType.INT8: ENTRY("b", 1), - DataType.INT16: ENTRY("h", 1), - DataType.INT32: ENTRY("i", 2), - DataType.INT64: ENTRY("q", 4), - DataType.UINT8: ENTRY("c", 1), - DataType.UINT16: ENTRY("H", 1), - DataType.UINT32: ENTRY("I", 2), - DataType.UINT64: ENTRY("Q", 4), - DataType.FLOAT16: ENTRY("e", 1), - DataType.FLOAT32: ENTRY("f", 2), - DataType.FLOAT64: ENTRY("d", 4), - DataType.STRING: ENTRY("s", 1), + DataType.INT8: ENTRY("b", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.UINT8: ENTRY("c", 1, PARM_IS_LEGAL(False, False, False, False, False)), + DataType.INT16: ENTRY("h", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.UINT16: ENTRY("H", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.FLOAT16: ENTRY("e", 1, PARM_IS_LEGAL(False, False, True, True, False)), + DataType.INT32: ENTRY("i", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.UINT32: ENTRY("I", 2, PARM_IS_LEGAL(False, False, True, True, True)), + DataType.FLOAT32: ENTRY("f", 2, PARM_IS_LEGAL(False, False, True, True, True)), + 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.CUSTOM: ENTRY("?", 0, PARM_IS_LEGAL(True, True, False, False, False)), } def struct_validator(config: dict[str, Any]) -> dict[str, Any]: """Sensor schema validator.""" - data_type = config[CONF_DATA_TYPE] - count = config.get(CONF_COUNT, 1) name = config[CONF_NAME] - structure = config.get(CONF_STRUCTURE) - slave_count = config.get(CONF_SLAVE_COUNT, 0) + 1 + data_type = config[CONF_DATA_TYPE] + if data_type == "int": + data_type = config[CONF_DATA_TYPE] = DataType.INT16 + count = config.get(CONF_COUNT, None) + structure = config.get(CONF_STRUCTURE, None) + slave_count = config.get(CONF_SLAVE_COUNT, None) swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) - if ( - slave_count > 1 - and count > 1 - and data_type not in (DataType.CUSTOM, DataType.STRING) - ): - error = f"{name} {CONF_COUNT} cannot be mixed with {data_type}" + validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm + if count and not validator.count: + error = f"{name}: `{CONF_COUNT}: {count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) - if config[CONF_DATA_TYPE] != DataType.CUSTOM: - if structure: - error = f"{name} structure: cannot be mixed with {data_type}" - - if config[CONF_DATA_TYPE] == DataType.CUSTOM: - if slave_count > 1: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SLAVE_COUNT}` / `{CONF_SLAVE}`" - raise vol.Invalid(error) - if swap_type != CONF_SWAP_NONE: - error = f"{name}: `{CONF_STRUCTURE}` illegal with `{CONF_SWAP}`" - raise vol.Invalid(error) - if not structure: - error = ( - f"Error in sensor {name}. The `{CONF_STRUCTURE}` field cannot be empty" - ) + if not count and validator.count: + error = f"{name}: `{CONF_COUNT}:` missing, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if structure and not validator.structure: + error = f"{name}: `{CONF_STRUCTURE}: {structure}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if not structure and validator.structure: + error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if slave_count and not validator.slave_count: + error = f"{name}: `{CONF_SLAVE_COUNT}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + raise vol.Invalid(error) + if swap_type != CONF_SWAP_NONE: + swap_type_validator = { + CONF_SWAP_NONE: False, + CONF_SWAP_BYTE: validator.swap_byte, + CONF_SWAP_WORD: validator.swap_word, + CONF_SWAP_WORD_BYTE: validator.swap_word, + }[swap_type] + if not swap_type_validator: + error = f"{name}: `{CONF_SWAP}:{swap_type}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) + if config[CONF_DATA_TYPE] == DataType.CUSTOM: try: size = struct.calcsize(structure) except struct.error as err: - raise vol.Invalid(f"Error in {name} structure: {str(err)}") from err - - count = config.get(CONF_COUNT, 1) + raise vol.Invalid( + f"{name}: error in structure format --> {str(err)}" + ) from err bytecount = count * 2 if bytecount != size: raise vol.Invalid( - f"Structure request {size} bytes, " - f"but {count} registers have a size of {bytecount} bytes" + f"{name}: Size of structure is {size} bytes but `{CONF_COUNT}: {count}` is {bytecount} bytes" ) - return { - **config, - CONF_STRUCTURE: structure, - CONF_SWAP: swap_type, - } - if data_type not in DEFAULT_STRUCT_FORMAT: - error = f"Error in sensor {name}. data_type `{data_type}` not supported" - raise vol.Invalid(error) - if slave_count > 1 and data_type == DataType.STRING: - error = f"{name}: `{data_type}` illegal with `{CONF_SLAVE_COUNT}`" - raise vol.Invalid(error) - - if CONF_COUNT not in config: + else: config[CONF_COUNT] = DEFAULT_STRUCT_FORMAT[data_type].register_count - if swap_type != CONF_SWAP_NONE: - if swap_type == CONF_SWAP_BYTE: - regs_needed = 1 - else: # CONF_SWAP_WORD_BYTE, CONF_SWAP_WORD - regs_needed = 2 - count = config[CONF_COUNT] - if count < regs_needed or (count % regs_needed) != 0: - raise vol.Invalid( - f"Error in sensor {name} swap({swap_type}) " - f"impossible because datatype({data_type}) is too small" + if slave_count: + structure = ( + f">{slave_count + 1}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" ) - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - if slave_count > 1: - structure = f">{slave_count}{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" - else: - structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" + else: + structure = f">{DEFAULT_STRUCT_FORMAT[data_type].struct_id}" return { **config, CONF_STRUCTURE: structure, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 12d5d558408609..a746bcda3ba53b 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -187,7 +187,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - "Structure request 16 bytes, but 2 registers have a size of 4 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 16 bytes but `{CONF_COUNT}: 2` is 4 bytes", ), ( { @@ -212,12 +212,11 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "", }, ] }, - f"Error in sensor {TEST_ENTITY_NAME}. The `structure` field cannot be empty", + f"{TEST_ENTITY_NAME}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ( { @@ -227,12 +226,11 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: CONF_ADDRESS: 1234, CONF_DATA_TYPE: DataType.CUSTOM, CONF_COUNT: 4, - CONF_SWAP: CONF_SWAP_NONE, CONF_STRUCTURE: "1s", }, ] }, - "Structure request 1 bytes, but 4 registers have a size of 8 bytes", + f"{TEST_ENTITY_NAME}: Size of structure is 1 bytes but `{CONF_COUNT}: 4` is 8 bytes", ), ( { @@ -247,7 +245,7 @@ async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: }, ] }, - f"{TEST_ENTITY_NAME}: `structure` illegal with `swap`", + f"{TEST_ENTITY_NAME}: `{CONF_SWAP}:{CONF_SWAP_WORD}` cannot be combined with `{CONF_DATA_TYPE}: {DataType.CUSTOM}`", ), ], ) @@ -1011,7 +1009,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No [ ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1020,7 +1017,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 1, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, }, @@ -1029,7 +1025,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_NONE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1038,7 +1033,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1047,7 +1041,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, }, @@ -1056,7 +1049,6 @@ async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> No ), ( { - CONF_COUNT: 2, CONF_SWAP: CONF_SWAP_WORD_BYTE, CONF_DATA_TYPE: DataType.UINT32, }, From f52ba7042d3c6b3ce21b95b512f6bbb34a26ce6d Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sun, 3 Sep 2023 21:31:25 +0200 Subject: [PATCH 052/640] Bump aiounifi to v60 (#99548) --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index 363313bf878dcf..cb1c8f1c0dc1c1 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==58"], + "requirements": ["aiounifi==60"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 453eeac3f6ead0..63602441668547 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==60 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3a2c66c337e6e0..f0de1bd23ec106 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==58 +aiounifi==60 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 8afab4845af95cdb144cc80b6e6e04b3df417087 Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Sun, 3 Sep 2023 13:52:03 -0700 Subject: [PATCH 053/640] Remove nest legacy service descriptions and translations (#99510) --- homeassistant/components/nest/services.yaml | 46 ------------------ homeassistant/components/nest/strings.json | 52 --------------------- 2 files changed, 98 deletions(-) delete mode 100644 homeassistant/components/nest/services.yaml diff --git a/homeassistant/components/nest/services.yaml b/homeassistant/components/nest/services.yaml deleted file mode 100644 index 5f68bd6a1f2db6..00000000000000 --- a/homeassistant/components/nest/services.yaml +++ /dev/null @@ -1,46 +0,0 @@ -# Describes the format for available Nest services - -set_away_mode: - fields: - away_mode: - required: true - selector: - select: - options: - - "away" - - "home" - structure: - example: "Apartment" - selector: - object: - -set_eta: - fields: - eta: - required: true - selector: - time: - eta_window: - default: "00:01" - selector: - time: - trip_id: - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: - -cancel_eta: - fields: - trip_id: - required: true - example: "Leave Work" - selector: - text: - structure: - example: "Apartment" - selector: - object: diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json index 2c2def6b7a38d0..717ce5075f7794 100644 --- a/homeassistant/components/nest/strings.json +++ b/homeassistant/components/nest/strings.json @@ -68,57 +68,5 @@ "title": "Legacy Works With Nest has been removed", "description": "Legacy Works With Nest has been removed from Home Assistant, and the API shuts down as of September 2023.\n\nYou must take action to use the SDM API. Remove all `nest` configuration from `configuration.yaml` and restart Home Assistant, then see the Nest [integration instructions]({documentation_url}) for set up instructions and supported devices." } - }, - "services": { - "set_away_mode": { - "name": "Set away mode", - "description": "Sets the away mode for a Nest structure.", - "fields": { - "away_mode": { - "name": "Away mode", - "description": "New mode to set." - }, - "structure": { - "name": "Structure", - "description": "Name(s) of structure(s) to change. Defaults to all structures if not specified." - } - } - }, - "set_eta": { - "name": "Set estimated time of arrival", - "description": "Sets or update the estimated time of arrival window for a Nest structure.", - "fields": { - "eta": { - "name": "ETA", - "description": "Estimated time of arrival from now." - }, - "eta_window": { - "name": "ETA window", - "description": "Estimated time of arrival window." - }, - "trip_id": { - "name": "Trip ID", - "description": "Unique ID for the trip. Default is auto-generated using a timestamp." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - }, - "cancel_eta": { - "name": "Cancel ETA", - "description": "Cancels an existing estimated time of arrival window for a Nest structure.", - "fields": { - "trip_id": { - "name": "[%key:component::nest::services::set_eta::fields::trip_id::name%]", - "description": "Unique ID for the trip." - }, - "structure": { - "name": "[%key:component::nest::services::set_away_mode::fields::structure::name%]", - "description": "[%key:component::nest::services::set_away_mode::fields::structure::description%]" - } - } - } } } From 377d9f6687372a4877b23d0cc434b71cf1dd3005 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 3 Sep 2023 17:06:21 -0500 Subject: [PATCH 054/640] Bump zeroconf to 0.96.0 (#99549) --- 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 718f3047a073b7..53b0dd5f5b5b45 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.93.1"] + "requirements": ["zeroconf==0.96.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c1b5f5a947f5cb..d1f86cbbd84d82 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.93.1 +zeroconf==0.96.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 63602441668547..2a01cfc6f7dfa7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.93.1 +zeroconf==0.96.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0de1bd23ec106..b0246a2226ff23 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.93.1 +zeroconf==0.96.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 115518cab9b4542907032cb37fc733d44a7e97af Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Sep 2023 02:58:01 +0200 Subject: [PATCH 055/640] Fix tolo test warning (#99555) --- tests/components/tolo/test_config_flow.py | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/tests/components/tolo/test_config_flow.py b/tests/components/tolo/test_config_flow.py index aa88766c395507..df36570497bf3b 100644 --- a/tests/components/tolo/test_config_flow.py +++ b/tests/components/tolo/test_config_flow.py @@ -23,6 +23,18 @@ def toloclient_fixture() -> Mock: yield toloclient +@pytest.fixture +def coordinator_toloclient() -> Mock: + """Patch ToloClient in async_setup_entry. + + Throw exception to abort entry setup and prevent socket IO. Only testing config flow. + """ + with patch( + "homeassistant.components.tolo.ToloClient", side_effect=Exception + ) as toloclient: + yield toloclient + + async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) -> None: """Test a user initiated config flow with provided host which times out.""" toloclient().get_status_info.side_effect = ResponseTimedOutError() @@ -38,7 +50,9 @@ async def test_user_with_timed_out_host(hass: HomeAssistant, toloclient: Mock) - assert result["errors"] == {"base": "cannot_connect"} -async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_user_walkthrough( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test complete user flow with first wrong and then correct host.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -70,7 +84,9 @@ async def test_user_walkthrough(hass: HomeAssistant, toloclient: Mock) -> None: assert result3["data"][CONF_HOST] == "127.0.0.1" -async def test_dhcp(hass: HomeAssistant, toloclient: Mock) -> None: +async def test_dhcp( + hass: HomeAssistant, toloclient: Mock, coordinator_toloclient: Mock +) -> None: """Test starting a flow from discovery.""" toloclient().get_status_info.side_effect = lambda *args, **kwargs: object() From 735b5cf1a015b493ede0f18586c3bf8e5584cebf Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 4 Sep 2023 02:58:13 +0200 Subject: [PATCH 056/640] Fix sql test warning (#99556) --- tests/components/sql/test_sensor.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/sql/test_sensor.py b/tests/components/sql/test_sensor.py index 3d0e2768adea66..cb988d3f2d47c1 100644 --- a/tests/components/sql/test_sensor.py +++ b/tests/components/sql/test_sensor.py @@ -502,6 +502,9 @@ async def test_multiple_sensors_using_same_db( assert state.state == "5" assert state.attributes["value"] == 5 + with patch("sqlalchemy.engine.base.Engine.dispose"): + await hass.async_stop() + async def test_engine_is_disposed_at_stop( recorder_mock: Recorder, hass: HomeAssistant From 9d6cab8fe604072f3e6aff1de8b29364e388eefa Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 07:33:46 +0200 Subject: [PATCH 057/640] Fix loading filesize coordinator from wrong place (#99547) * Fix loading filesize coordinator from wrong place * aboslute in executor * combine into executor --- homeassistant/components/filesize/__init__.py | 14 ++++-- .../components/filesize/coordinator.py | 48 +++++++++++++++++++ homeassistant/components/filesize/sensor.py | 45 ++--------------- 3 files changed, 62 insertions(+), 45 deletions(-) create mode 100644 homeassistant/components/filesize/coordinator.py diff --git a/homeassistant/components/filesize/__init__.py b/homeassistant/components/filesize/__init__.py index 73f060e79b7044..9d7cc99421f59a 100644 --- a/homeassistant/components/filesize/__init__.py +++ b/homeassistant/components/filesize/__init__.py @@ -9,10 +9,11 @@ from homeassistant.exceptions import ConfigEntryNotReady from .const import PLATFORMS +from .coordinator import FileSizeCoordinator -def _check_path(hass: HomeAssistant, path: str) -> None: - """Check if path is valid and allowed.""" +def _get_full_path(hass: HomeAssistant, path: str) -> str: + """Check if path is valid, allowed and return full path.""" get_path = pathlib.Path(path) if not get_path.exists() or not get_path.is_file(): raise ConfigEntryNotReady(f"Can not access file {path}") @@ -20,10 +21,17 @@ def _check_path(hass: HomeAssistant, path: str) -> None: if not hass.config.is_allowed_path(path): raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed") + return str(get_path.absolute()) + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH]) + full_path = await hass.async_add_executor_job( + _get_full_path, hass, entry.data[CONF_FILE_PATH] + ) + coordinator = FileSizeCoordinator(hass, full_path) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) return True diff --git a/homeassistant/components/filesize/coordinator.py b/homeassistant/components/filesize/coordinator.py new file mode 100644 index 00000000000000..75411f8497557f --- /dev/null +++ b/homeassistant/components/filesize/coordinator.py @@ -0,0 +1,48 @@ +"""Coordinator for monitoring the size of a file.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging +import os + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +import homeassistant.util.dt as dt_util + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FileSizeCoordinator(DataUpdateCoordinator[dict[str, int | float | datetime]]): + """Filesize coordinator.""" + + def __init__(self, hass: HomeAssistant, path: str) -> None: + """Initialize filesize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=timedelta(seconds=60), + always_update=False, + ) + self._path = path + + async def _async_update_data(self) -> dict[str, float | int | datetime]: + """Fetch file information.""" + try: + statinfo = await self.hass.async_add_executor_job(os.stat, self._path) + except OSError as error: + raise UpdateFailed(f"Can not retrieve file statistics {error}") from error + + size = statinfo.st_size + last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) + + _LOGGER.debug("size %s, last updated %s", size, last_updated) + data: dict[str, int | float | datetime] = { + "file": round(size / 1e6, 2), + "bytes": size, + "last_updated": last_updated, + } + + return data diff --git a/homeassistant/components/filesize/sensor.py b/homeassistant/components/filesize/sensor.py index 0e60036364074b..c8e5dae5892c3c 100644 --- a/homeassistant/components/filesize/sensor.py +++ b/homeassistant/components/filesize/sensor.py @@ -1,9 +1,8 @@ """Sensor for monitoring the size of a file.""" from __future__ import annotations -from datetime import datetime, timedelta +from datetime import datetime import logging -import os import pathlib from homeassistant.components.sensor import ( @@ -17,14 +16,10 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, - UpdateFailed, -) -import homeassistant.util.dt as dt_util +from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN +from .coordinator import FileSizeCoordinator _LOGGER = logging.getLogger(__name__) @@ -80,40 +75,6 @@ async def async_setup_entry( ) -class FileSizeCoordinator(DataUpdateCoordinator): - """Filesize coordinator.""" - - def __init__(self, hass: HomeAssistant, path: str) -> None: - """Initialize filesize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=timedelta(seconds=60), - always_update=False, - ) - self._path = path - - async def _async_update_data(self) -> dict[str, float | int | datetime]: - """Fetch file information.""" - try: - statinfo = await self.hass.async_add_executor_job(os.stat, self._path) - except OSError as error: - raise UpdateFailed(f"Can not retrieve file statistics {error}") from error - - size = statinfo.st_size - last_updated = dt_util.utc_from_timestamp(statinfo.st_mtime) - - _LOGGER.debug("size %s, last updated %s", size, last_updated) - data: dict[str, int | float | datetime] = { - "file": round(size / 1e6, 2), - "bytes": size, - "last_updated": last_updated, - } - - return data - - class FilesizeEntity(CoordinatorEntity[FileSizeCoordinator], SensorEntity): """Filesize sensor.""" From 1dc724274e5dee341d804ca13b20b62596f07e3b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:05:59 +0200 Subject: [PATCH 058/640] Use shorthand attributes for Heos (#99344) --- homeassistant/components/heos/media_player.py | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index e2487e90a991a1..8502dec28faf70 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -114,6 +114,8 @@ class HeosMediaPlayer(MediaPlayerEntity): _attr_media_content_type = MediaType.MUSIC _attr_should_poll = False + _attr_supported_features = BASE_SUPPORTED_FEATURES + _attr_media_image_remotely_accessible = True _attr_has_entity_name = True _attr_name = None @@ -122,9 +124,16 @@ def __init__(self, player): self._media_position_updated_at = None self._player = player self._signals = [] - self._attr_supported_features = BASE_SUPPORTED_FEATURES self._source_manager = None self._group_manager = None + self._attr_unique_id = str(player.player_id) + self._attr_device_info = DeviceInfo( + identifiers={(HEOS_DOMAIN, player.player_id)}, + manufacturer="HEOS", + model=player.model, + name=player.name, + sw_version=player.version, + ) async def _player_update(self, player_id, event): """Handle player attribute updated.""" @@ -306,17 +315,6 @@ def available(self) -> bool: """Return True if the device is available.""" return self._player.available - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - identifiers={(HEOS_DOMAIN, self._player.player_id)}, - manufacturer="HEOS", - model=self._player.model, - name=self._player.name, - sw_version=self._player.version, - ) - @property def extra_state_attributes(self) -> dict[str, Any]: """Get additional attribute about the state.""" @@ -377,11 +375,6 @@ def media_position_updated_at(self): return None return self._media_position_updated_at - @property - def media_image_remotely_accessible(self) -> bool: - """If the image url is remotely accessible.""" - return True - @property def media_image_url(self) -> str: """Image url of current playing media.""" @@ -414,11 +407,6 @@ def state(self) -> MediaPlayerState: """State of the player.""" return PLAY_STATE_TO_STATE[self._player.state] - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return str(self._player.player_id) - @property def volume_level(self) -> float: """Volume level of the media player (0..1).""" From 8d3828ae5400933106b51f8016e973091d06bc48 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 10:07:15 +0300 Subject: [PATCH 059/640] Add strict typing to glances (#99537) --- .strict-typing | 1 + homeassistant/components/glances/coordinator.py | 3 ++- homeassistant/components/glances/sensor.py | 7 +++++-- mypy.ini | 10 ++++++++++ 4 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 2a6e9b04cbe7ac..4a4151ce606e14 100644 --- a/.strict-typing +++ b/.strict-typing @@ -136,6 +136,7 @@ homeassistant.components.fully_kiosk.* homeassistant.components.geo_location.* homeassistant.components.geocaching.* homeassistant.components.gios.* +homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_sheets.* diff --git a/homeassistant/components/glances/coordinator.py b/homeassistant/components/glances/coordinator.py index 24a2e23a013f75..8d2bd0daaa3fac 100644 --- a/homeassistant/components/glances/coordinator.py +++ b/homeassistant/components/glances/coordinator.py @@ -35,6 +35,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry, api: Glances) -> Non async def _async_update_data(self) -> dict[str, Any]: """Get the latest data from the Glances REST API.""" try: - return await self.api.get_ha_sensor_data() + data = await self.api.get_ha_sensor_data() except exceptions.GlancesApiError as err: raise UpdateFailed from err + return data or {} diff --git a/homeassistant/components/glances/sensor.py b/homeassistant/components/glances/sensor.py index cd9c3a9135dc18..78aa5ffbf0a16f 100644 --- a/homeassistant/components/glances/sensor.py +++ b/homeassistant/components/glances/sensor.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import cast from homeassistant.components.sensor import ( SensorDeviceClass, @@ -346,5 +347,7 @@ def native_value(self) -> StateType: value = self.coordinator.data[self.entity_description.type] if isinstance(value.get(self._sensor_name_prefix), dict): - return value[self._sensor_name_prefix][self.entity_description.key] - return value[self.entity_description.key] + return cast( + StateType, value[self._sensor_name_prefix][self.entity_description.key] + ) + return cast(StateType, value[self.entity_description.key]) diff --git a/mypy.ini b/mypy.ini index 178b82fd359c0e..14eb6bba841281 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1122,6 +1122,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.glances.*] +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.goalzero.*] check_untyped_defs = true disallow_incomplete_defs = true From f545389549df23ae5c5ed49e77f9c253516d53d2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:08:32 +0200 Subject: [PATCH 060/640] Move static shorthand devolo attributes outside of constructor (#99234) Co-authored-by: Guido Schmitz --- .../components/devolo_home_control/climate.py | 13 +++++----- .../components/devolo_home_control/cover.py | 25 +++++-------------- .../components/devolo_home_control/light.py | 3 ++- .../components/devolo_home_control/sensor.py | 17 ++++++++----- .../components/devolo_home_control/switch.py | 2 +- 5 files changed, 27 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/devolo_home_control/climate.py b/homeassistant/components/devolo_home_control/climate.py index 227b479688384d..e27d5a315a5fe7 100644 --- a/homeassistant/components/devolo_home_control/climate.py +++ b/homeassistant/components/devolo_home_control/climate.py @@ -50,6 +50,13 @@ async def async_setup_entry( class DevoloClimateDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, ClimateEntity): """Representation of a climate/thermostat device within devolo Home Control.""" + _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE + _attr_target_temperature_step = PRECISION_HALVES + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_precision = PRECISION_TENTHS + _attr_hvac_mode = HVACMode.HEAT + _attr_hvac_modes = [HVACMode.HEAT] + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -60,14 +67,8 @@ def __init__( element_uid=element_uid, ) - self._attr_hvac_mode = HVACMode.HEAT - self._attr_hvac_modes = [HVACMode.HEAT] self._attr_min_temp = self._multi_level_switch_property.min self._attr_max_temp = self._multi_level_switch_property.max - self._attr_precision = PRECISION_TENTHS - self._attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - self._attr_target_temperature_step = PRECISION_HALVES - self._attr_temperature_unit = UnitOfTemperature.CELSIUS @property def current_temperature(self) -> float | None: diff --git a/homeassistant/components/devolo_home_control/cover.py b/homeassistant/components/devolo_home_control/cover.py index a23c3fde585eea..b76948bcee7e9f 100644 --- a/homeassistant/components/devolo_home_control/cover.py +++ b/homeassistant/components/devolo_home_control/cover.py @@ -3,9 +3,6 @@ from typing import Any -from devolo_home_control_api.devices.zwave import Zwave -from devolo_home_control_api.homecontrol import HomeControl - from homeassistant.components.cover import ( CoverDeviceClass, CoverEntity, @@ -43,22 +40,12 @@ async def async_setup_entry( class DevoloCoverDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, CoverEntity): """Representation of a cover device within devolo Home Control.""" - def __init__( - self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str - ) -> None: - """Initialize a climate entity within devolo Home Control.""" - super().__init__( - homecontrol=homecontrol, - device_instance=device_instance, - element_uid=element_uid, - ) - - self._attr_device_class = CoverDeviceClass.BLIND - self._attr_supported_features = ( - CoverEntityFeature.OPEN - | CoverEntityFeature.CLOSE - | CoverEntityFeature.SET_POSITION - ) + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.SET_POSITION + ) + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int: diff --git a/homeassistant/components/devolo_home_control/light.py b/homeassistant/components/devolo_home_control/light.py index 93a66e345ecb2b..e91466c7ecec68 100644 --- a/homeassistant/components/devolo_home_control/light.py +++ b/homeassistant/components/devolo_home_control/light.py @@ -39,6 +39,8 @@ async def async_setup_entry( class DevoloLightDeviceEntity(DevoloMultiLevelSwitchDeviceEntity, LightEntity): """Representation of a light within devolo Home Control.""" + _attr_color_mode = ColorMode.BRIGHTNESS + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -49,7 +51,6 @@ def __init__( element_uid=element_uid, ) - self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} self._binary_switch_property = device_instance.binary_switch_property.get( element_uid.replace("Dimmer", "BinarySwitch") diff --git a/homeassistant/components/devolo_home_control/sensor.py b/homeassistant/components/devolo_home_control/sensor.py index b7e2a30b4c1bd8..fa11424ae94b29 100644 --- a/homeassistant/components/devolo_home_control/sensor.py +++ b/homeassistant/components/devolo_home_control/sensor.py @@ -123,6 +123,12 @@ def __init__( class DevoloBatteryEntity(DevoloMultiLevelDeviceEntity): """Representation of a battery entity within devolo Home Control.""" + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_name = "Battery level" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_state_class = SensorStateClass.MEASUREMENT + def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: @@ -134,11 +140,6 @@ def __init__( element_uid=element_uid, ) - self._attr_device_class = DEVICE_CLASS_MAPPING.get("battery") - self._attr_state_class = STATE_CLASS_MAPPING.get("battery") - self._attr_entity_category = EntityCategory.DIAGNOSTIC - self._attr_native_unit_of_measurement = PERCENTAGE - self._attr_name = "Battery level" self._value = device_instance.battery_level @@ -175,7 +176,11 @@ def __init__( @property def unique_id(self) -> str: - """Return the unique ID of the entity.""" + """Return the unique ID of the entity. + + As both sensor types share the same element_uid we need to extend original + self._attr_unique_id to be really unique. + """ return f"{self._attr_unique_id}_{self._sensor_type}" def _sync(self, message: tuple) -> None: diff --git a/homeassistant/components/devolo_home_control/switch.py b/homeassistant/components/devolo_home_control/switch.py index 9b96e58da60062..c442cc55763c04 100644 --- a/homeassistant/components/devolo_home_control/switch.py +++ b/homeassistant/components/devolo_home_control/switch.py @@ -46,7 +46,7 @@ class DevoloSwitch(DevoloDeviceEntity, SwitchEntity): def __init__( self, homecontrol: HomeControl, device_instance: Zwave, element_uid: str ) -> None: - """Initialize an devolo Switch.""" + """Initialize a devolo Switch.""" super().__init__( homecontrol=homecontrol, device_instance=device_instance, From f4f98010f9dd2018d87302ba030feac91632ef99 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:15:01 +0200 Subject: [PATCH 061/640] Remove unused attributes from Econet (#99242) --- homeassistant/components/econet/__init__.py | 7 +----- homeassistant/components/econet/climate.py | 22 +++++-------------- .../components/econet/water_heater.py | 12 +++++----- 3 files changed, 13 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 3005993bf99e7c..36cdeb688218a1 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -13,7 +13,7 @@ ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform, UnitOfTemperature +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.device_registry import DeviceInfo @@ -137,8 +137,3 @@ def device_info(self) -> DeviceInfo: manufacturer="Rheem", name=self._econet.device_name, ) - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.FAHRENHEIT diff --git a/homeassistant/components/econet/climate.py b/homeassistant/components/econet/climate.py index 7233d135f2e1cd..e77c4face7477a 100644 --- a/homeassistant/components/econet/climate.py +++ b/homeassistant/components/econet/climate.py @@ -16,7 +16,7 @@ HVACMode, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE +from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -62,23 +62,21 @@ async def async_setup_entry( class EcoNetThermostat(EcoNetEntity, ClimateEntity): - """Define a Econet thermostat.""" + """Define an Econet thermostat.""" + + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, thermostat): """Initialize.""" super().__init__(thermostat) - self._running = thermostat.running - self._poll = True - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} - self.op_list = [] + self._attr_hvac_modes = [] for mode in self._econet.modes: if mode not in [ ThermostatOperationMode.UNKNOWN, ThermostatOperationMode.EMERGENCY_HEAT, ]: ha_mode = ECONET_STATE_TO_HA[mode] - self.op_list.append(ha_mode) + self._attr_hvac_modes.append(ha_mode) @property def supported_features(self) -> ClimateEntityFeature: @@ -142,14 +140,6 @@ def is_aux_heat(self): """Return true if aux heater.""" return self._econet.mode == ThermostatOperationMode.EMERGENCY_HEAT - @property - def hvac_modes(self): - """Return hvac operation ie. heat, cool mode. - - Needs to be one of HVAC_MODE_*. - """ - return self.op_list - @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool, mode. diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index c94afd8b5d77f2..cbaf4551d03161 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -16,7 +16,7 @@ WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, UnitOfTemperature from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -56,20 +56,20 @@ async def async_setup_entry( class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): - """Define a Econet water heater.""" + """Define an Econet water heater.""" + + _attr_should_poll = True # Override False default from EcoNetEntity + _attr_temperature_unit = UnitOfTemperature.FAHRENHEIT def __init__(self, water_heater): """Initialize.""" super().__init__(water_heater) self._running = water_heater.running - self._attr_should_poll = True # Override False default from EcoNetEntity self.water_heater = water_heater - self.econet_state_to_ha = {} - self.ha_state_to_econet = {} @callback def on_update_received(self): - """Update was pushed from the ecoent API.""" + """Update was pushed from the econet API.""" if self._running != self.water_heater.running: # Water heater running state has changed so check usage on next update self._attr_should_poll = True From 13ebb68b84f4856a9f62a7d6d72a893b74afca4a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:15:25 +0200 Subject: [PATCH 062/640] Use shorthand attributes in Home connect (#99385) Co-authored-by: Robert Resch --- .../components/home_connect/entity.py | 30 +++-------- .../components/home_connect/light.py | 52 +++++++----------- .../components/home_connect/sensor.py | 53 +++++++------------ .../components/home_connect/switch.py | 35 ++++-------- 4 files changed, 55 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 12fe7be3be99f0..d60f8a96e0982a 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -21,8 +21,14 @@ class HomeConnectEntity(Entity): def __init__(self, device: HomeConnectDevice, desc: str) -> None: """Initialize the entity.""" self.device = device - self.desc = desc - self._name = f"{self.device.appliance.name} {desc}" + self._attr_name = f"{device.appliance.name} {desc}" + self._attr_unique_id = f"{device.appliance.haId}-{desc}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.appliance.haId)}, + manufacturer=device.appliance.brand, + model=device.appliance.vib, + name=device.appliance.name, + ) async def async_added_to_hass(self): """Register callbacks.""" @@ -38,26 +44,6 @@ def _update_callback(self, ha_id): if ha_id == self.device.appliance.haId: self.async_entity_update() - @property - def name(self): - """Return the name of the node (used for Entity_ID).""" - return self._name - - @property - def unique_id(self): - """Return the unique id base on the id returned by Home Connect and the entity name.""" - return f"{self.device.appliance.haId}-{self.desc}" - - @property - def device_info(self) -> DeviceInfo: - """Return info about the device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.device.appliance.haId)}, - manufacturer=self.device.appliance.brand, - model=self.device.appliance.vib, - name=self.device.appliance.name, - ) - @callback def async_entity_update(self): """Update the entity.""" diff --git a/homeassistant/components/home_connect/light.py b/homeassistant/components/home_connect/light.py index 17dc842358f963..7e65fed034d08b 100644 --- a/homeassistant/components/home_connect/light.py +++ b/homeassistant/components/home_connect/light.py @@ -59,11 +59,8 @@ class HomeConnectLight(HomeConnectEntity, LightEntity): def __init__(self, device, desc, ambient): """Initialize the entity.""" super().__init__(device, desc) - self._state = None - self._brightness = None - self._hs_color = None self._ambient = ambient - if self._ambient: + if ambient: self._brightness_key = BSH_AMBIENT_LIGHT_BRIGHTNESS self._key = BSH_AMBIENT_LIGHT_ENABLED self._custom_color_key = BSH_AMBIENT_LIGHT_CUSTOM_COLOR @@ -78,21 +75,6 @@ def __init__(self, device, desc, ambient): self._attr_color_mode = ColorMode.BRIGHTNESS self._attr_supported_color_modes = {ColorMode.BRIGHTNESS} - @property - def is_on(self): - """Return true if the light is on.""" - return bool(self._state) - - @property - def brightness(self): - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self): - """Return the color property.""" - return self._hs_color - async def async_turn_on(self, **kwargs: Any) -> None: """Switch the light on, change brightness, change color.""" if self._ambient: @@ -113,12 +95,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying selecting customcolor: %s", err) - if self._brightness is not None: - brightness = 10 + ceil(self._brightness / 255 * 90) + if self._attr_brightness is not None: + brightness = 10 + ceil(self._attr_brightness / 255 * 90) if ATTR_BRIGHTNESS in kwargs: brightness = 10 + ceil(kwargs[ATTR_BRIGHTNESS] / 255 * 90) - hs_color = kwargs.get(ATTR_HS_COLOR, self._hs_color) + hs_color = kwargs.get(ATTR_HS_COLOR, self._attr_hs_color) if hs_color is not None: rgb = color_util.color_hsv_to_RGB( @@ -170,32 +152,34 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Update the light's status.""" if self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is True: - self._state = True + self._attr_is_on = True elif self.device.appliance.status.get(self._key, {}).get(ATTR_VALUE) is False: - self._state = False + self._attr_is_on = False else: - self._state = None + self._attr_is_on = None - _LOGGER.debug("Updated, new light state: %s", self._state) + _LOGGER.debug("Updated, new light state: %s", self._attr_is_on) if self._ambient: color = self.device.appliance.status.get(self._custom_color_key, {}) if not color: - self._hs_color = None - self._brightness = None + self._attr_hs_color = None + self._attr_brightness = None else: colorvalue = color.get(ATTR_VALUE)[1:] rgb = color_util.rgb_hex_to_rgb_list(colorvalue) hsv = color_util.color_RGB_to_hsv(rgb[0], rgb[1], rgb[2]) - self._hs_color = [hsv[0], hsv[1]] - self._brightness = ceil((hsv[2] - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_hs_color = (hsv[0], hsv[1]) + self._attr_brightness = ceil((hsv[2] - 10) * 255 / 90) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) else: brightness = self.device.appliance.status.get(self._brightness_key, {}) if brightness is None: - self._brightness = None + self._attr_brightness = None else: - self._brightness = ceil((brightness.get(ATTR_VALUE) - 10) * 255 / 90) - _LOGGER.debug("Updated, new brightness: %s", self._brightness) + self._attr_brightness = ceil( + (brightness.get(ATTR_VALUE) - 10) * 255 / 90 + ) + _LOGGER.debug("Updated, new brightness: %s", self._attr_brightness) diff --git a/homeassistant/components/home_connect/sensor.py b/homeassistant/components/home_connect/sensor.py index efd2a9b34dd483..07edfb4bd4b040 100644 --- a/homeassistant/components/home_connect/sensor.py +++ b/homeassistant/components/home_connect/sensor.py @@ -1,6 +1,7 @@ """Provides a sensor for Home Connect.""" -from datetime import timedelta +from datetime import datetime, timedelta import logging +from typing import cast from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry @@ -40,62 +41,44 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity): def __init__(self, device, desc, key, unit, icon, device_class, sign=1): """Initialize the entity.""" super().__init__(device, desc) - self._state = None self._key = key - self._unit = unit - self._icon = icon - self._device_class = device_class self._sign = sign - - @property - def native_value(self): - """Return sensor value.""" - return self._state + self._attr_native_unit_of_measurement = unit + self._attr_icon = icon + self._attr_device_class = device_class @property def available(self) -> bool: """Return true if the sensor is available.""" - return self._state is not None + return self._attr_native_value is not None async def async_update(self) -> None: """Update the sensor's status.""" status = self.device.appliance.status if self._key not in status: - self._state = None + self._attr_native_value = None elif self.device_class == SensorDeviceClass.TIMESTAMP: if ATTR_VALUE not in status[self._key]: - self._state = None + self._attr_native_value = None elif ( - self._state is not None + self._attr_native_value is not None and self._sign == 1 - and self._state < dt_util.utcnow() + and isinstance(self._attr_native_value, datetime) + and self._attr_native_value < dt_util.utcnow() ): # if the date is supposed to be in the future but we're # already past it, set state to None. - self._state = None + self._attr_native_value = None else: seconds = self._sign * float(status[self._key][ATTR_VALUE]) - self._state = dt_util.utcnow() + timedelta(seconds=seconds) + self._attr_native_value = dt_util.utcnow() + timedelta(seconds=seconds) else: - self._state = status[self._key].get(ATTR_VALUE) + self._attr_native_value = status[self._key].get(ATTR_VALUE) if self._key == BSH_OPERATION_STATE: # Value comes back as an enum, we only really care about the # last part, so split it off # https://developer.home-connect.com/docs/status/operation_state - self._state = self._state.split(".")[-1] - _LOGGER.debug("Updated, new state: %s", self._state) - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit - - @property - def icon(self): - """Return the icon.""" - return self._icon - - @property - def device_class(self): - """Return the device class.""" - return self._device_class + self._attr_native_value = cast(str, self._attr_native_value).split(".")[ + -1 + ] + _LOGGER.debug("Updated, new state: %s", self._attr_native_value) diff --git a/homeassistant/components/home_connect/switch.py b/homeassistant/components/home_connect/switch.py index 61dd11dbc6f248..dbcbfde9dc2e77 100644 --- a/homeassistant/components/home_connect/switch.py +++ b/homeassistant/components/home_connect/switch.py @@ -56,13 +56,6 @@ def __init__(self, device, program_name): ) super().__init__(device, desc) self.program_name = program_name - self._state = None - self._remote_allowed = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Start the program.""" @@ -88,10 +81,10 @@ async def async_update(self) -> None: """Update the switch's status.""" state = self.device.appliance.status.get(BSH_ACTIVE_PROGRAM, {}) if state.get(ATTR_VALUE) == self.program_name: - self._state = True + self._attr_is_on = True else: - self._state = False - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = False + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): @@ -100,12 +93,6 @@ class HomeConnectPowerSwitch(HomeConnectEntity, SwitchEntity): def __init__(self, device): """Inititialize the entity.""" super().__init__(device, "Power") - self._state = None - - @property - def is_on(self): - """Return true if the switch is on.""" - return bool(self._state) async def async_turn_on(self, **kwargs: Any) -> None: """Switch the device on.""" @@ -116,7 +103,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn on device: %s", err) - self._state = False + self._attr_is_on = False self.async_entity_update() async def async_turn_off(self, **kwargs: Any) -> None: @@ -130,7 +117,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) except HomeConnectError as err: _LOGGER.error("Error while trying to turn off device: %s", err) - self._state = True + self._attr_is_on = True self.async_entity_update() async def async_update(self) -> None: @@ -139,12 +126,12 @@ async def async_update(self) -> None: self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == BSH_POWER_ON ): - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_POWER_STATE, {}).get(ATTR_VALUE) == self.device.power_off_state ): - self._state = False + self._attr_is_on = False elif self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get( ATTR_VALUE, None ) in [ @@ -156,12 +143,12 @@ async def async_update(self) -> None: "BSH.Common.EnumType.OperationState.Aborting", "BSH.Common.EnumType.OperationState.Finished", ]: - self._state = True + self._attr_is_on = True elif ( self.device.appliance.status.get(BSH_OPERATION_STATE, {}).get(ATTR_VALUE) == "BSH.Common.EnumType.OperationState.Inactive" ): - self._state = False + self._attr_is_on = False else: - self._state = None - _LOGGER.debug("Updated, new state: %s", self._state) + self._attr_is_on = None + _LOGGER.debug("Updated, new state: %s", self._attr_is_on) From 69117cb8e3cff03721473b96f66b9048e1184945 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 09:23:33 +0200 Subject: [PATCH 063/640] Use shorthand attributes for DLNA dmr (#99236) --- homeassistant/components/dlna_dmr/media_player.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dlna_dmr/media_player.py b/homeassistant/components/dlna_dmr/media_player.py index 50877756d521fc..3a57ba2c8ced22 100644 --- a/homeassistant/components/dlna_dmr/media_player.py +++ b/homeassistant/components/dlna_dmr/media_player.py @@ -129,6 +129,9 @@ class DlnaDmrEntity(MediaPlayerEntity): # determine whether further device polling is required. _attr_should_poll = True + # Name of the current sound mode, not supported by DLNA + _attr_sound_mode = None + def __init__( self, udn: str, @@ -745,11 +748,6 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: "Couldn't find a suitable mode for shuffle=%s, repeat=%s", shuffle, repeat ) - @property - def sound_mode(self) -> str | None: - """Name of the current sound mode, not supported by DLNA.""" - return None - @property def sound_mode_list(self) -> list[str] | None: """List of available sound modes.""" From 9144ef7ed8c04ebfe5d8c240685400beccc42922 Mon Sep 17 00:00:00 2001 From: Russell Cloran Date: Mon, 4 Sep 2023 00:30:56 -0700 Subject: [PATCH 064/640] Enumerate available states in Prometheus startup (#97993) --- .../components/prometheus/__init__.py | 24 +++++++++++----- tests/components/prometheus/test_init.py | 28 +++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index adc5225b28602a..1818f308239353 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -120,10 +120,15 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool: default_metric, ) - hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed) + hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_state_changed_event) hass.bus.listen( EVENT_ENTITY_REGISTRY_UPDATED, metrics.handle_entity_registry_updated ) + + for state in hass.states.all(): + if entity_filter(state.entity_id): + metrics.handle_state(state) + return True @@ -162,16 +167,13 @@ def __init__( self._metrics = {} self._climate_units = climate_units - def handle_state_changed(self, event): - """Listen for new messages on the bus, and add them to Prometheus.""" + def handle_state_changed_event(self, event): + """Handle new messages from the bus.""" if (state := event.data.get("new_state")) is None: return - entity_id = state.entity_id - _LOGGER.debug("Handling state update for %s", entity_id) - domain, _ = hacore.split_entity_id(entity_id) - if not self._filter(state.entity_id): + _LOGGER.debug("Filtered out entity %s", state.entity_id) return if (old_state := event.data.get("old_state")) is not None and ( @@ -179,6 +181,14 @@ def handle_state_changed(self, event): ) != state.attributes.get(ATTR_FRIENDLY_NAME): self._remove_labelsets(old_state.entity_id, old_friendly_name) + self.handle_state(state) + + def handle_state(self, state): + """Add/update a state in Prometheus.""" + entity_id = state.entity_id + _LOGGER.debug("Handling state update for %s", entity_id) + domain, _ = hacore.split_entity_id(entity_id) + ignored_states = (STATE_UNAVAILABLE, STATE_UNKNOWN) handler = f"_handle_{domain}" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 82a205eb259bd7..07a666946fb7ac 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -107,6 +107,34 @@ async def generate_latest_metrics(client): return body +@pytest.mark.parametrize("namespace", [""]) +async def test_setup_enumeration(hass, hass_client, entity_registry, namespace): + """Test that setup enumerates existing states/entities.""" + + # The order of when things are created must be carefully controlled in + # this test, so we don't use fixtures. + + sensor_1 = entity_registry.async_get_or_create( + domain=sensor.DOMAIN, + platform="test", + unique_id="sensor_1", + unit_of_measurement=UnitOfTemperature.CELSIUS, + original_device_class=SensorDeviceClass.TEMPERATURE, + suggested_object_id="outside_temperature", + original_name="Outside Temperature", + ) + set_state_with_entry(hass, sensor_1, 12.3, {}) + assert await async_setup_component(hass, prometheus.DOMAIN, {prometheus.DOMAIN: {}}) + + client = await hass_client() + body = await generate_latest_metrics(client) + assert ( + 'homeassistant_sensor_temperature_celsius{domain="sensor",' + 'entity="sensor.outside_temperature",' + 'friendly_name="Outside Temperature"} 12.3' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_view_empty_namespace(client, sensor_entities) -> None: """Test prometheus metrics view.""" From de1de926a9bd86d7173e854e4db903233ebfb1f1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 02:52:21 -0500 Subject: [PATCH 065/640] Bump zeroconf to 0.97.0 (#99554) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.96.0...0.97.0 --- 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 53b0dd5f5b5b45..4969b2a5a65188 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.96.0"] + "requirements": ["zeroconf==0.97.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index d1f86cbbd84d82..a1d4a0c7bf9181 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.96.0 +zeroconf==0.97.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 2a01cfc6f7dfa7..c70cbe30d5f572 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.2.4 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.96.0 +zeroconf==0.97.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0246a2226ff23..487b2ecf4b7236 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.2.4 # homeassistant.components.zeroconf -zeroconf==0.96.0 +zeroconf==0.97.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From b9536732bc0ddda42bba58641a978b3fff9ead0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiit=20R=C3=A4tsep?= Date: Mon, 4 Sep 2023 11:03:58 +0300 Subject: [PATCH 066/640] Fix battery reading in SOMA API (#99403) Co-authored-by: Robert Resch --- homeassistant/components/soma/sensor.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/soma/sensor.py b/homeassistant/components/soma/sensor.py index 6472f6934e00dc..d1c0de188a08e4 100644 --- a/homeassistant/components/soma/sensor.py +++ b/homeassistant/components/soma/sensor.py @@ -43,11 +43,12 @@ def native_value(self): async def async_update(self) -> None: """Update the sensor with the latest data.""" response = await self.get_battery_level_from_api() - - # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API - # battery_level response is expected to be min = 360, max 410 for - # 0-100% levels above 410 are consider 100% and below 360, 0% as the - # device considers 360 the minimum to move the motor. - _battery = round(2 * (response["battery_level"] - 360)) + _battery = response.get("battery_percentage") + if _battery is None: + # https://support.somasmarthome.com/hc/en-us/articles/360026064234-HTTP-API + # battery_level response is expected to be min = 360, max 410 for + # 0-100% levels above 410 are consider 100% and below 360, 0% as the + # device considers 360 the minimum to move the motor. + _battery = round(2 * (response["battery_level"] - 360)) battery = max(min(100, _battery), 0) self.battery_state = battery From fa0b61e96a907d955edf49d9288860f9cddfa984 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 4 Sep 2023 11:07:08 +0200 Subject: [PATCH 067/640] Move london underground coordinator to its own file (#99550) --- CODEOWNERS | 1 + .../components/london_underground/const.py | 26 +++++++++ .../london_underground/coordinator.py | 30 +++++++++++ .../london_underground/manifest.json | 2 +- .../components/london_underground/sensor.py | 53 ++----------------- 5 files changed, 62 insertions(+), 50 deletions(-) create mode 100644 homeassistant/components/london_underground/const.py create mode 100644 homeassistant/components/london_underground/coordinator.py diff --git a/CODEOWNERS b/CODEOWNERS index b937c2769fce18..42537d4e3f18c4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -707,6 +707,7 @@ build.json @home-assistant/supervisor /tests/components/logger/ @home-assistant/core /homeassistant/components/logi_circle/ @evanjd /tests/components/logi_circle/ @evanjd +/homeassistant/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco /homeassistant/components/loqed/ @mikewoudenberg diff --git a/homeassistant/components/london_underground/const.py b/homeassistant/components/london_underground/const.py new file mode 100644 index 00000000000000..4928d3bb1641d5 --- /dev/null +++ b/homeassistant/components/london_underground/const.py @@ -0,0 +1,26 @@ +"""Constants for the London underground integration.""" +from datetime import timedelta + +DOMAIN = "london_underground" + +CONF_LINE = "line" + + +SCAN_INTERVAL = timedelta(seconds=30) + +TUBE_LINES = [ + "Bakerloo", + "Central", + "Circle", + "District", + "DLR", + "Elizabeth line", + "Hammersmith & City", + "Jubilee", + "London Overground", + "Metropolitan", + "Northern", + "Piccadilly", + "Victoria", + "Waterloo & City", +] diff --git a/homeassistant/components/london_underground/coordinator.py b/homeassistant/components/london_underground/coordinator.py new file mode 100644 index 00000000000000..a094d099896835 --- /dev/null +++ b/homeassistant/components/london_underground/coordinator.py @@ -0,0 +1,30 @@ +"""DataUpdateCoordinator for London underground integration.""" +from __future__ import annotations + +import asyncio +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class LondonTubeCoordinator(DataUpdateCoordinator): + """London Underground sensor coordinator.""" + + def __init__(self, hass, data): + """Initialize coordinator.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._data = data + + async def _async_update_data(self): + async with asyncio.timeout(10): + await self._data.update() + return self._data.data diff --git a/homeassistant/components/london_underground/manifest.json b/homeassistant/components/london_underground/manifest.json index acdb83a2359df5..eafc63c6ae7386 100644 --- a/homeassistant/components/london_underground/manifest.json +++ b/homeassistant/components/london_underground/manifest.json @@ -1,7 +1,7 @@ { "domain": "london_underground", "name": "London Underground", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/london_underground", "iot_class": "cloud_polling", "loggers": ["london_tube_status"], diff --git a/homeassistant/components/london_underground/sensor.py b/homeassistant/components/london_underground/sensor.py index 7e52186fa51763..c0d0eeca372f83 100644 --- a/homeassistant/components/london_underground/sensor.py +++ b/homeassistant/components/london_underground/sensor.py @@ -1,8 +1,6 @@ """Sensor for checking the status of London Underground tube lines.""" from __future__ import annotations -import asyncio -from datetime import timedelta import logging from london_tube_status import TubeData @@ -15,36 +13,12 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = "london_underground" - -CONF_LINE = "line" - +from homeassistant.helpers.update_coordinator import CoordinatorEntity -SCAN_INTERVAL = timedelta(seconds=30) +from .const import CONF_LINE, TUBE_LINES +from .coordinator import LondonTubeCoordinator -TUBE_LINES = [ - "Bakerloo", - "Central", - "Circle", - "District", - "DLR", - "Elizabeth line", - "Hammersmith & City", - "Jubilee", - "London Overground", - "Metropolitan", - "Northern", - "Piccadilly", - "Victoria", - "Waterloo & City", -] +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Required(CONF_LINE): vol.All(cv.ensure_list, [vol.In(list(TUBE_LINES))])} @@ -76,25 +50,6 @@ async def async_setup_platform( async_add_entities(sensors) -class LondonTubeCoordinator(DataUpdateCoordinator): - """London Underground sensor coordinator.""" - - def __init__(self, hass, data): - """Initialize coordinator.""" - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - self._data = data - - async def _async_update_data(self): - async with asyncio.timeout(10): - await self._data.update() - return self._data.data - - class LondonTubeSensor(CoordinatorEntity[LondonTubeCoordinator], SensorEntity): """Sensor that reads the status of a line from Tube Data.""" From 051e9e7498b7f1d702a78c7a5321b2d828d290d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:15:02 +0200 Subject: [PATCH 068/640] Use shorthand attributes in Laundrify (#99586) --- .../components/laundrify/binary_sensor.py | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/laundrify/binary_sensor.py b/homeassistant/components/laundrify/binary_sensor.py index 81882b68f006fd..5cca6870b6c0ff 100644 --- a/homeassistant/components/laundrify/binary_sensor.py +++ b/homeassistant/components/laundrify/binary_sensor.py @@ -51,17 +51,14 @@ def __init__( """Pass coordinator to CoordinatorEntity.""" super().__init__(coordinator) self._device = device - self._attr_unique_id = device["_id"] - - @property - def device_info(self) -> DeviceInfo: - """Configure the Device of this Entity.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device["_id"])}, - name=self._device["name"], + unique_id = device["_id"] + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + name=device["name"], manufacturer=MANUFACTURER, model=MODEL, - sw_version=self._device["firmwareVersion"], + sw_version=device["firmwareVersion"], ) @property From 890eed11211c496aff9f8b038203b9d9a573954b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:18:55 +0200 Subject: [PATCH 069/640] Use shorthand attributes in LCN (#99587) --- homeassistant/components/lcn/binary_sensor.py | 26 +---- homeassistant/components/lcn/cover.py | 108 ++++++------------ homeassistant/components/lcn/light.py | 40 ++----- homeassistant/components/lcn/sensor.py | 30 ++--- homeassistant/components/lcn/switch.py | 30 ++--- 5 files changed, 70 insertions(+), 164 deletions(-) diff --git a/homeassistant/components/lcn/binary_sensor.py b/homeassistant/components/lcn/binary_sensor.py index 13a2a5b3bb32fc..ceeeecf50c4e83 100644 --- a/homeassistant/components/lcn/binary_sensor.py +++ b/homeassistant/components/lcn/binary_sensor.py @@ -66,8 +66,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -84,11 +82,6 @@ async def async_will_remove_from_hass(self) -> None: self.setpoint_variable ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -97,7 +90,7 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._value = input_obj.get_value().is_locked_regulator() + self._attr_is_on = input_obj.get_value().is_locked_regulator() self.async_write_ha_state() @@ -114,8 +107,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -132,17 +123,12 @@ async def async_will_remove_from_hass(self) -> None: self.bin_sensor_port ) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusBinSensors): return - self._value = input_obj.get_state(self.bin_sensor_port.value) + self._attr_is_on = input_obj.get_state(self.bin_sensor_port.value) self.async_write_ha_state() @@ -156,7 +142,6 @@ def __init__( super().__init__(config, entry_id, device_connection) self.source = pypck.lcn_defs.Key[config[CONF_DOMAIN_DATA][CONF_SOURCE]] - self._value = None async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -170,11 +155,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def is_on(self) -> bool | None: - """Return true if the binary sensor is on.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -186,5 +166,5 @@ def input_received(self, input_obj: InputType) -> None: table_id = ord(self.source.name[0]) - 65 key_id = int(self.source.name[1]) - 1 - self._value = input_obj.get_state(table_id, key_id) + self._attr_is_on = input_obj.get_state(table_id, key_id) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/cover.py b/homeassistant/components/lcn/cover.py index bc83da55888244..31b2dbface02c5 100644 --- a/homeassistant/components/lcn/cover.py +++ b/homeassistant/components/lcn/cover.py @@ -51,6 +51,11 @@ async def async_setup_entry( class LcnOutputsCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to output ports.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -68,10 +73,6 @@ def __init__( else: self.reverse_time = None - self._is_closed = False - self._is_closing = False - self._is_opening = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -94,26 +95,6 @@ async def async_will_remove_from_hass(self) -> None: pypck.lcn_defs.OutputPort["OUTPUTDOWN"] ) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" state = pypck.lcn_defs.MotorStateModifier.DOWN @@ -121,8 +102,8 @@ async def async_close_cover(self, **kwargs: Any) -> None: state, self.reverse_time ): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -132,9 +113,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: state, self.reverse_time ): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -142,8 +123,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: state = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_outputs(state): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -156,17 +137,17 @@ def input_received(self, input_obj: InputType) -> None: if input_obj.get_percent() > 0: # motor is on if input_obj.get_output_id() == self.output_ids[0]: - self._is_opening = True - self._is_closing = False + self._attr_is_opening = True + self._attr_is_closing = False else: # self.output_ids[1] - self._is_opening = False - self._is_closing = True - self._is_closed = self._is_closing + self._attr_is_opening = False + self._attr_is_closing = True + self._attr_is_closed = self._attr_is_closing else: # motor is off # cover is assumed to be closed if we were in closing state before - self._is_closed = self._is_closing - self._is_closing = False - self._is_opening = False + self._attr_is_closed = self._attr_is_closing + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() @@ -174,6 +155,11 @@ def input_received(self, input_obj: InputType) -> None: class LcnRelayCover(LcnEntity, CoverEntity): """Representation of a LCN cover connected to relays.""" + _attr_is_closed = False + _attr_is_closing = False + _attr_is_opening = False + _attr_assumed_state = True + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -200,34 +186,14 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.motor) - @property - def is_closed(self) -> bool: - """Return if the cover is closed.""" - return self._is_closed - - @property - def is_opening(self) -> bool: - """Return if the cover is opening or not.""" - return self._is_opening - - @property - def is_closing(self) -> bool: - """Return if the cover is closing or not.""" - return self._is_closing - - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return True - async def async_close_cover(self, **kwargs: Any) -> None: """Close the cover.""" states = [pypck.lcn_defs.MotorStateModifier.NOCHANGE] * 4 states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.DOWN if not await self.device_connection.control_motors_relays(states): return - self._is_opening = False - self._is_closing = True + self._attr_is_opening = False + self._attr_is_closing = True self.async_write_ha_state() async def async_open_cover(self, **kwargs: Any) -> None: @@ -236,9 +202,9 @@ async def async_open_cover(self, **kwargs: Any) -> None: states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.UP if not await self.device_connection.control_motors_relays(states): return - self._is_closed = False - self._is_opening = True - self._is_closing = False + self._attr_is_closed = False + self._attr_is_opening = True + self._attr_is_closing = False self.async_write_ha_state() async def async_stop_cover(self, **kwargs: Any) -> None: @@ -247,8 +213,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: states[self.motor.value] = pypck.lcn_defs.MotorStateModifier.STOP if not await self.device_connection.control_motors_relays(states): return - self._is_closing = False - self._is_opening = False + self._attr_is_closing = False + self._attr_is_opening = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -258,11 +224,11 @@ def input_received(self, input_obj: InputType) -> None: states = input_obj.states # list of boolean values (relay on/off) if states[self.motor_port_onoff]: # motor is on - self._is_opening = not states[self.motor_port_updown] # set direction - self._is_closing = states[self.motor_port_updown] # set direction + self._attr_is_opening = not states[self.motor_port_updown] # set direction + self._attr_is_closing = states[self.motor_port_updown] # set direction else: # motor is off - self._is_opening = False - self._is_closing = False - self._is_closed = states[self.motor_port_updown] + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed = states[self.motor_port_updown] self.async_write_ha_state() diff --git a/homeassistant/components/lcn/light.py b/homeassistant/components/lcn/light.py index 38480cc3124750..65c1344edf0d91 100644 --- a/homeassistant/components/lcn/light.py +++ b/homeassistant/components/lcn/light.py @@ -65,6 +65,8 @@ class LcnOutputLight(LcnEntity, LightEntity): """Representation of a LCN light for output ports.""" _attr_supported_features = LightEntityFeature.TRANSITION + _attr_is_on = False + _attr_brightness = 255 def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -79,8 +81,6 @@ def __init__( ) self.dimmable = config[CONF_DOMAIN_DATA][CONF_DIMMABLE] - self._brightness = 255 - self._is_on = False self._is_dimming_to_zero = False if self.dimmable: @@ -101,16 +101,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def brightness(self) -> int | None: - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if ATTR_BRIGHTNESS in kwargs: @@ -128,7 +118,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.output.value, percent, transition ): return - self._is_on = True + self._attr_is_on = True self._is_dimming_to_zero = False self.async_write_ha_state() @@ -146,7 +136,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: ): return self._is_dimming_to_zero = bool(transition) - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -157,11 +147,11 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._brightness = int(input_obj.get_percent() / 100.0 * 255) - if self.brightness == 0: + self._attr_brightness = int(input_obj.get_percent() / 100.0 * 255) + if self._attr_brightness == 0: self._is_dimming_to_zero = False - if not self._is_dimming_to_zero and self.brightness is not None: - self._is_on = self.brightness > 0 + if not self._is_dimming_to_zero and self._attr_brightness is not None: + self._attr_is_on = self._attr_brightness > 0 self.async_write_ha_state() @@ -170,6 +160,7 @@ class LcnRelayLight(LcnEntity, LightEntity): _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} + _attr_is_on = False def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType @@ -179,8 +170,6 @@ def __init__( self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -193,18 +182,13 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -213,7 +197,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -221,5 +205,5 @@ def input_received(self, input_obj: InputType) -> None: if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() diff --git a/homeassistant/components/lcn/sensor.py b/homeassistant/components/lcn/sensor.py index 66321c79a1b021..1428019b59f922 100644 --- a/homeassistant/components/lcn/sensor.py +++ b/homeassistant/components/lcn/sensor.py @@ -77,8 +77,7 @@ def __init__( self.unit = pypck.lcn_defs.VarUnit.parse( config[CONF_DOMAIN_DATA][CONF_UNIT_OF_MEASUREMENT] ) - - self._value = None + self._attr_native_unit_of_measurement = cast(str, self.unit.value) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -92,16 +91,6 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.variable) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of this entity, if any.""" - return cast(str, self.unit.value) - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if ( @@ -110,7 +99,7 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._value = input_obj.get_value().to_var_unit(self.unit) + self._attr_native_value = input_obj.get_value().to_var_unit(self.unit) self.async_write_ha_state() @@ -130,8 +119,6 @@ def __init__( config[CONF_DOMAIN_DATA][CONF_SOURCE] ] - self._value = None - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -144,19 +131,18 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.source) - @property - def native_value(self) -> str | None: - """Return the state of the entity.""" - return self._value - def input_received(self, input_obj: InputType) -> None: """Set sensor value when LCN input object (command) is received.""" if not isinstance(input_obj, pypck.inputs.ModStatusLedsAndLogicOps): return if self.source in pypck.lcn_defs.LedPort: - self._value = input_obj.get_led_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_led_state( + self.source.value + ).name.lower() elif self.source in pypck.lcn_defs.LogicOpPort: - self._value = input_obj.get_logic_op_state(self.source.value).name.lower() + self._attr_native_value = input_obj.get_logic_op_state( + self.source.value + ).name.lower() self.async_write_ha_state() diff --git a/homeassistant/components/lcn/switch.py b/homeassistant/components/lcn/switch.py index ded15c0f1da094..8374ff85ab73ff 100644 --- a/homeassistant/components/lcn/switch.py +++ b/homeassistant/components/lcn/switch.py @@ -52,6 +52,8 @@ async def async_setup_entry( class LcnOutputSwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for output ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -60,8 +62,6 @@ def __init__( self.output = pypck.lcn_defs.OutputPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -74,23 +74,18 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" if not await self.device_connection.dim_output(self.output.value, 100, 0): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: """Turn the entity off.""" if not await self.device_connection.dim_output(self.output.value, 0, 0): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -101,13 +96,15 @@ def input_received(self, input_obj: InputType) -> None: ): return - self._is_on = input_obj.get_percent() > 0 + self._attr_is_on = input_obj.get_percent() > 0 self.async_write_ha_state() class LcnRelaySwitch(LcnEntity, SwitchEntity): """Representation of a LCN switch for relay ports.""" + _attr_is_on = False + def __init__( self, config: ConfigType, entry_id: str, device_connection: DeviceConnectionType ) -> None: @@ -116,8 +113,6 @@ def __init__( self.output = pypck.lcn_defs.RelayPort[config[CONF_DOMAIN_DATA][CONF_OUTPUT]] - self._is_on = False - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() @@ -130,18 +125,13 @@ async def async_will_remove_from_hass(self) -> None: if not self.device_connection.is_group: await self.device_connection.cancel_status_request_handler(self.output) - @property - def is_on(self) -> bool: - """Return True if entity is on.""" - return self._is_on - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on.""" states = [pypck.lcn_defs.RelayStateModifier.NOCHANGE] * 8 states[self.output.value] = pypck.lcn_defs.RelayStateModifier.ON if not await self.device_connection.control_relays(states): return - self._is_on = True + self._attr_is_on = True self.async_write_ha_state() async def async_turn_off(self, **kwargs: Any) -> None: @@ -150,7 +140,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: states[self.output.value] = pypck.lcn_defs.RelayStateModifier.OFF if not await self.device_connection.control_relays(states): return - self._is_on = False + self._attr_is_on = False self.async_write_ha_state() def input_received(self, input_obj: InputType) -> None: @@ -158,5 +148,5 @@ def input_received(self, input_obj: InputType) -> None: if not isinstance(input_obj, pypck.inputs.ModStatusRelays): return - self._is_on = input_obj.get_state(self.output.value) + self._attr_is_on = input_obj.get_state(self.output.value) self.async_write_ha_state() From 8ea3b877f651fc5dc207cda4b56320043d7ef48c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:21:30 +0200 Subject: [PATCH 070/640] Use shorthand attributes in Juicenet (#99575) --- homeassistant/components/juicenet/entity.py | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/juicenet/entity.py b/homeassistant/components/juicenet/entity.py index 3c325715c8245e..b3433948582819 100644 --- a/homeassistant/components/juicenet/entity.py +++ b/homeassistant/components/juicenet/entity.py @@ -23,20 +23,12 @@ def __init__( super().__init__(coordinator) self.device = device self.key = key - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self.device.id}-{self.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about this JuiceNet Device.""" - return DeviceInfo( + self._attr_unique_id = f"{device.id}-{key}" + self._attr_device_info = DeviceInfo( configuration_url=( - f"https://home.juice.net/Portal/Details?unitID={self.device.id}" + f"https://home.juice.net/Portal/Details?unitID={device.id}" ), - identifiers={(DOMAIN, self.device.id)}, + identifiers={(DOMAIN, device.id)}, manufacturer="JuiceNet", - name=self.device.name, + name=device.name, ) From cf2d3674b9fe16a11ab823fdcf52de41288cbf1c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:29:56 +0200 Subject: [PATCH 071/640] Use shorthand attributes in Kulersky (#99583) --- homeassistant/components/kulersky/light.py | 41 ++++++++-------------- 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/kulersky/light.py b/homeassistant/components/kulersky/light.py index c68633ab639692..6636bfdba9f33c 100644 --- a/homeassistant/components/kulersky/light.py +++ b/homeassistant/components/kulersky/light.py @@ -66,13 +66,19 @@ class KulerskyLight(LightEntity): _attr_has_entity_name = True _attr_name = None + _attr_available = False + _attr_supported_color_modes = {ColorMode.RGBW} + _attr_color_mode = ColorMode.RGBW def __init__(self, light: pykulersky.Light) -> None: """Initialize a Kuler Sky light.""" self._light = light - self._available = False - self._attr_supported_color_modes = {ColorMode.RGBW} - self._attr_color_mode = ColorMode.RGBW + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Brightech", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -91,30 +97,11 @@ async def async_will_remove_from_hass(self, *args) -> None: "Exception disconnected from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Brightech", - name=self._light.name, - ) - @property def is_on(self): """Return true if light is on.""" return self.brightness > 0 - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" default_rgbw = (255,) * 4 if self.rgbw_color is None else self.rgbw_color @@ -140,18 +127,18 @@ async def async_turn_off(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Fetch new state data for this light.""" try: - if not self._available: + if not self._attr_available: await self._light.connect() rgbw = await self._light.get_color() except pykulersky.PykulerskyException as exc: - if self._available: + if self._attr_available: _LOGGER.warning("Unable to connect to %s: %s", self._light.address, exc) - self._available = False + self._attr_available = False return - if self._available is False: + if self._attr_available is False: _LOGGER.info("Reconnected to %s", self._light.address) - self._available = True + self._attr_available = True brightness = max(rgbw) if not brightness: self._attr_rgbw_color = (0, 0, 0, 0) From 6194f7faea6f53ab131a64ff783e254301f59495 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:39:24 +0200 Subject: [PATCH 072/640] Bump pyenphase to 1.9.1 (#99574) --- 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 540c121bb17025..a45f4f01e49a9f 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.8.1"], + "requirements": ["pyenphase==1.9.1"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c70cbe30d5f572..f2369f1cf7f139 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.9.1 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 487b2ecf4b7236..c3adc7ddf98d6c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.8.1 +pyenphase==1.9.1 # homeassistant.components.everlights pyeverlights==0.1.0 From d5301fba90f6c2a7633a89d2648eaec6023788b4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 13:50:05 +0200 Subject: [PATCH 073/640] Use shorthand attributes in Keenetic (#99577) --- .../components/keenetic_ndms2/binary_sensor.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/keenetic_ndms2/binary_sensor.py b/homeassistant/components/keenetic_ndms2/binary_sensor.py index f39c92519e432c..ab0b33701976e8 100644 --- a/homeassistant/components/keenetic_ndms2/binary_sensor.py +++ b/homeassistant/components/keenetic_ndms2/binary_sensor.py @@ -33,22 +33,14 @@ class RouterOnlineBinarySensor(BinarySensorEntity): def __init__(self, router: KeeneticRouter) -> None: """Initialize the APCUPSd binary device.""" self._router = router - - @property - def unique_id(self) -> str: - """Return a unique identifier for this device.""" - return f"online_{self._router.config_entry.entry_id}" + self._attr_unique_id = f"online_{router.config_entry.entry_id}" + self._attr_device_info = router.device_info @property def is_on(self): """Return true if the UPS is online, else false.""" return self._router.available - @property - def device_info(self): - """Return a client description for device registry.""" - return self._router.device_info - async def async_added_to_hass(self) -> None: """Client entity created.""" self.async_on_remove( From f3d8a0eaafd3083fb028777b1fcc338555f3bb15 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:08:38 +0200 Subject: [PATCH 074/640] Don't set assumed_state in cover groups (#99391) --- homeassistant/components/group/cover.py | 20 +------------------- tests/components/group/test_cover.py | 13 +++++++------ 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/group/cover.py b/homeassistant/components/group/cover.py index dbb49222bb0b93..d22184c0922f48 100644 --- a/homeassistant/components/group/cover.py +++ b/homeassistant/components/group/cover.py @@ -17,7 +17,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -44,7 +43,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import attribute_equal, reduce_attribute +from .util import reduce_attribute KEY_OPEN_CLOSE = "open_close" KEY_STOP = "stop" @@ -116,7 +115,6 @@ class CoverGroup(GroupEntity, CoverEntity): _attr_is_opening: bool | None = False _attr_is_closing: bool | None = False _attr_current_cover_position: int | None = 100 - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a CoverGroup entity.""" @@ -251,8 +249,6 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False - states = [ state.state for entity_id in self._entity_ids @@ -293,9 +289,6 @@ def async_update_group_state(self) -> None: self._attr_current_cover_position = reduce_attribute( position_states, ATTR_CURRENT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - position_states, ATTR_CURRENT_POSITION - ) tilt_covers = self._tilts[KEY_POSITION] all_tilt_states = [self.hass.states.get(x) for x in tilt_covers] @@ -303,9 +296,6 @@ def async_update_group_state(self) -> None: self._attr_current_cover_tilt_position = reduce_attribute( tilt_states, ATTR_CURRENT_TILT_POSITION ) - self._attr_assumed_state |= not attribute_equal( - tilt_states, ATTR_CURRENT_TILT_POSITION - ) supported_features = CoverEntityFeature(0) if self._covers[KEY_OPEN_CLOSE]: @@ -322,11 +312,3 @@ def async_update_group_state(self) -> None: if self._tilts[KEY_POSITION]: supported_features |= CoverEntityFeature.SET_TILT_POSITION self._attr_supported_features = supported_features - - if not self._attr_assumed_state: - for entity_id in self._entity_ids: - if (state := self.hass.states.get(entity_id)) is None: - continue - if state and state.attributes.get(ATTR_ASSUMED_STATE): - self._attr_assumed_state = True - break diff --git a/tests/components/group/test_cover.py b/tests/components/group/test_cover.py index 84ccba2ff6663a..4e0ddc19a312af 100644 --- a/tests/components/group/test_cover.py +++ b/tests/components/group/test_cover.py @@ -346,10 +346,10 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_CURRENT_POSITION] == 70 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # ### Test assumed state ### + # ### Test state when group members have different states ### # ########################## - # For covers - assumed state set true if position differ + # Covers hass.states.async_set( DEMO_COVER, STATE_OPEN, {ATTR_SUPPORTED_FEATURES: 4, ATTR_CURRENT_POSITION: 100} ) @@ -357,7 +357,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 244 assert state.attributes[ATTR_CURRENT_POSITION] == 85 # (70 + 100) / 2 assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 @@ -373,7 +373,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 60 - # For tilts - assumed state set true if tilt position differ + # Tilts hass.states.async_set( DEMO_TILT, STATE_OPEN, @@ -383,7 +383,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(COVER_GROUP) assert state.state == STATE_OPEN - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_SUPPORTED_FEATURES] == 128 assert ATTR_CURRENT_POSITION not in state.attributes assert state.attributes[ATTR_CURRENT_TILT_POSITION] == 80 # (60 + 100) / 2 @@ -399,11 +399,12 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert ATTR_CURRENT_POSITION not in state.attributes assert ATTR_CURRENT_TILT_POSITION not in state.attributes + # Group member has set assumed_state hass.states.async_set(DEMO_TILT, STATE_CLOSED, {ATTR_ASSUMED_STATE: True}) await hass.async_block_till_done() state = hass.states.get(COVER_GROUP) - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes # Test entity registry integration entity_registry = er.async_get(hass) From 6223af189988cee90f9b78fa71cb5007c54c53dd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:08:50 +0200 Subject: [PATCH 075/640] Don't set assumed_state in fan groups (#99399) --- homeassistant/components/group/fan.py | 18 +----------------- tests/components/group/test_config_flow.py | 2 +- tests/components/group/test_fan.py | 17 ++++------------- 3 files changed, 6 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/group/fan.py b/homeassistant/components/group/fan.py index 4ee788c840249d..4e3bb824266193 100644 --- a/homeassistant/components/group/fan.py +++ b/homeassistant/components/group/fan.py @@ -25,7 +25,6 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( - ATTR_ASSUMED_STATE, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, CONF_ENTITIES, @@ -41,12 +40,7 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import GroupEntity -from .util import ( - attribute_equal, - most_frequent_attribute, - reduce_attribute, - states_equal, -) +from .util import attribute_equal, most_frequent_attribute, reduce_attribute SUPPORTED_FLAGS = { FanEntityFeature.SET_SPEED, @@ -110,7 +104,6 @@ class FanGroup(GroupEntity, FanEntity): """Representation of a FanGroup.""" _attr_available: bool = False - _attr_assumed_state: bool = True def __init__(self, unique_id: str | None, name: str, entities: list[str]) -> None: """Initialize a FanGroup entity.""" @@ -243,19 +236,16 @@ def _set_attr_most_frequent(self, attr: str, flag: int, entity_attr: str) -> Non """Set an attribute based on most frequent supported entities attributes.""" states = self._async_states_by_support_flag(flag) setattr(self, attr, most_frequent_attribute(states, entity_attr)) - self._attr_assumed_state |= not attribute_equal(states, entity_attr) @callback def async_update_group_state(self) -> None: """Update state and attributes.""" - self._attr_assumed_state = False states = [ state for entity_id in self._entity_ids if (state := self.hass.states.get(entity_id)) is not None ] - self._attr_assumed_state |= not states_equal(states) # Set group as unavailable if all members are unavailable or missing self._attr_available = any(state.state != STATE_UNAVAILABLE for state in states) @@ -274,9 +264,6 @@ def async_update_group_state(self) -> None: FanEntityFeature.SET_SPEED ) self._percentage = reduce_attribute(percentage_states, ATTR_PERCENTAGE) - self._attr_assumed_state |= not attribute_equal( - percentage_states, ATTR_PERCENTAGE - ) if ( percentage_states and percentage_states[0].attributes.get(ATTR_PERCENTAGE_STEP) @@ -301,6 +288,3 @@ def async_update_group_state(self) -> None: ior, [feature for feature in SUPPORTED_FLAGS if self._fans[feature]], 0 ) ) - self._attr_assumed_state |= any( - state.attributes.get(ATTR_ASSUMED_STATE) for state in states - ) diff --git a/tests/components/group/test_config_flow.py b/tests/components/group/test_config_flow.py index d0e90fe61bdc63..1c8275c7f2deb5 100644 --- a/tests/components/group/test_config_flow.py +++ b/tests/components/group/test_config_flow.py @@ -468,7 +468,7 @@ async def test_options_flow_hides_members( COVER_ATTRS = [{"supported_features": 0}, {}] EVENT_ATTRS = [{"event_types": []}, {"event_type": None}] -FAN_ATTRS = [{"supported_features": 0}, {"assumed_state": True}] +FAN_ATTRS = [{"supported_features": 0}, {}] LIGHT_ATTRS = [ { "icon": "mdi:lightbulb-group", diff --git a/tests/components/group/test_fan.py b/tests/components/group/test_fan.py index 6269df3fed7553..2272a29f6edf3f 100644 --- a/tests/components/group/test_fan.py +++ b/tests/components/group/test_fan.py @@ -247,11 +247,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_PERCENTAGE] == 50 assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different speed should set assumed state + # Add Entity with a different speed should not set assumed state hass.states.async_set( PERCENTAGE_LIMITED_FAN_ENTITY_ID, STATE_ON, @@ -264,7 +260,7 @@ async def test_attributes(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert state.attributes[ATTR_PERCENTAGE] == int((50 + 75) / 2) @@ -306,11 +302,7 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: assert state.attributes[ATTR_DIRECTION] == DIRECTION_FORWARD assert ATTR_ASSUMED_STATE not in state.attributes - # Add Entity that supports - # ### Test assumed state ### - # ########################## - - # Add Entity with a different direction should set assumed state + # Add Entity with a different direction should not set assumed state hass.states.async_set( PERCENTAGE_FULL_FAN_ENTITY_ID, STATE_ON, @@ -325,11 +317,10 @@ async def test_direction_oscillating(hass: HomeAssistant, setup_comp) -> None: state = hass.states.get(FAN_GROUP) assert state.state == STATE_ON - assert state.attributes[ATTR_ASSUMED_STATE] is True + assert ATTR_ASSUMED_STATE not in state.attributes assert ATTR_PERCENTAGE in state.attributes assert state.attributes[ATTR_PERCENTAGE] == 50 assert state.attributes[ATTR_OSCILLATING] is True - assert ATTR_ASSUMED_STATE in state.attributes # Now that everything is the same, no longer assumed state From 709ce7e0af91c2bccb1299b4d060ad176e6b3a4b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:09:51 +0200 Subject: [PATCH 076/640] Set state of entity with invalid state to unknown (#99452) * Set state of entity with invalid state to unknown * Add test * Apply suggestions from code review Co-authored-by: Robert Resch * Update test_entity.py --------- Co-authored-by: Robert Resch --- homeassistant/helpers/entity.py | 16 ++++++++++++++-- tests/helpers/test_entity.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 42 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 29a944874abe10..e946c41d3b8be2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -35,7 +35,11 @@ EntityCategory, ) from homeassistant.core import CALLBACK_TYPE, Context, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError, NoEntitySpecifiedError +from homeassistant.exceptions import ( + HomeAssistantError, + InvalidStateError, + NoEntitySpecifiedError, +) from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util, ensure_unique_string, slugify @@ -848,7 +852,15 @@ def _async_write_ha_state(self) -> None: self._context = None self._context_set = None - hass.states.async_set(entity_id, state, attr, self.force_update, self._context) + try: + hass.states.async_set( + entity_id, state, attr, self.force_update, self._context + ) + except InvalidStateError: + _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) + hass.states.async_set( + entity_id, STATE_UNKNOWN, {}, self.force_update, self._context + ) def schedule_update_ha_state(self, force_refresh: bool = False) -> None: """Schedule an update ha state change task. diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 200b0230adb2d8..20bea6a98eb172 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -3,6 +3,7 @@ from collections.abc import Iterable import dataclasses from datetime import timedelta +import logging import threading from typing import Any from unittest.mock import MagicMock, PropertyMock, patch @@ -1477,3 +1478,30 @@ async def test_warn_no_platform( caplog.clear() ent.async_write_ha_state() assert error_message not in caplog.text + + +async def test_invalid_state( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test the entity helper catches InvalidState and sets state to unknown.""" + ent = entity.Entity() + ent.entity_id = "test.test" + ent.hass = hass + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 + + caplog.clear() + ent._attr_state = "x" * 256 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == STATE_UNKNOWN + assert ( + "homeassistant.helpers.entity", + logging.ERROR, + f"Failed to set state, fall back to {STATE_UNKNOWN}", + ) in caplog.record_tuples + + ent._attr_state = "x" * 255 + ent.async_write_ha_state() + assert hass.states.get("test.test").state == "x" * 255 From 7c595ee2da562b909fcbbd4b30ce11126cc5f58f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:10:43 +0200 Subject: [PATCH 077/640] Validate state in template helper preview (#99455) * Validate state in template helper preview * Deduplicate state validation --- .../components/template/template_entity.py | 13 ++++++++++--- homeassistant/core.py | 16 +++++++++++----- tests/test_core.py | 7 +++++++ 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index ac06e2c8734f9d..c33674fa86f25c 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -25,6 +25,7 @@ HomeAssistant, State, callback, + validate_state, ) from homeassistant.exceptions import TemplateError import homeassistant.helpers.config_validation as cv @@ -413,8 +414,8 @@ def _handle_results( return for update in updates: - for attr in self._template_attrs[update.template]: - attr.handle_result( + for template_attr in self._template_attrs[update.template]: + template_attr.handle_result( event, update.template, update.last_result, update.result ) @@ -422,7 +423,13 @@ def _handle_results( self.async_write_ha_state() return - self._preview_callback(*self._async_generate_attributes(), None) + try: + state, attrs = self._async_generate_attributes() + validate_state(state) + except Exception as err: # pylint: disable=broad-exception-caught + self._preview_callback(None, None, str(err)) + else: + self._preview_callback(state, attrs, None) @callback def _async_template_startup(self, *_: Any) -> None: diff --git a/homeassistant/core.py b/homeassistant/core.py index bd5967807593b1..47a8119de71b45 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -174,6 +174,16 @@ def valid_entity_id(entity_id: str) -> bool: return VALID_ENTITY_ID.match(entity_id) is not None +def validate_state(state: str) -> str: + """Validate a state, raise if it not valid.""" + if len(state) > MAX_LENGTH_STATE_STATE: + raise InvalidStateError( + f"Invalid state with length {len(state)}. " + "State max length is 255 characters." + ) + return state + + def callback(func: _CallableT) -> _CallableT: """Annotation to mark method as safe to call from within the event loop.""" setattr(func, "_hass_callback", True) @@ -1255,11 +1265,7 @@ def __init__( "Format should be ." ) - if len(state) > MAX_LENGTH_STATE_STATE: - raise InvalidStateError( - f"Invalid state encountered for entity ID: {entity_id}. " - "State max length is 255 characters." - ) + validate_state(state) self.entity_id = entity_id self.state = state diff --git a/tests/test_core.py b/tests/test_core.py index f4a80468050df6..5dcbb81db68af6 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2465,3 +2465,10 @@ def run_job(job: HassJob) -> None: # Cleanup timer2.cancel() + + +async def test_validate_state(hass: HomeAssistant) -> None: + """Test validate_state.""" + assert ha.validate_state("test") == "test" + with pytest.raises(InvalidStateError): + ha.validate_state("t" * 256) From 7643820e5919f2817e393d7bac4e83443d11fa93 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 14:12:33 +0200 Subject: [PATCH 078/640] Add loader.async_get_loaded_integration (#99440) * Add loader.async_get_loaded_integration * Decorate async_get_loaded_integration with @callback --- homeassistant/core.py | 5 ++++- homeassistant/loader.py | 27 ++++++++++++++++++++++++++- tests/test_loader.py | 7 +++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 47a8119de71b45..3648fca99f73ba 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -32,7 +32,7 @@ import voluptuous as vol import yarl -from . import block_async_io, loader, util +from . import block_async_io, util from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -310,6 +310,9 @@ def __repr__(self) -> str: def __init__(self, config_dir: str) -> None: """Initialize new Home Assistant object.""" + # pylint: disable-next=import-outside-toplevel + from . import loader + self.loop = asyncio.get_running_loop() self._tasks: set[asyncio.Future[Any]] = set() self._background_tasks: set[asyncio.Future[Any]] = set() diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 40161bd3be955f..8906cefb24197e 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -25,6 +25,7 @@ import voluptuous as vol from . import generated +from .core import HomeAssistant, callback from .generated.application_credentials import APPLICATION_CREDENTIALS from .generated.bluetooth import BLUETOOTH from .generated.dhcp import DHCP @@ -37,7 +38,6 @@ # Typing imports that create a circular dependency if TYPE_CHECKING: from .config_entries import ConfigEntry - from .core import HomeAssistant from .helpers import device_registry as dr from .helpers.typing import ConfigType @@ -875,6 +875,22 @@ def _resolve_integrations_from_root( return integrations +@callback +def async_get_loaded_integration(hass: HomeAssistant, domain: str) -> Integration: + """Get an integration which is already loaded. + + Raises IntegrationNotLoaded if the integration is not loaded. + """ + cache = hass.data[DATA_INTEGRATIONS] + if TYPE_CHECKING: + cache = cast(dict[str, Integration | asyncio.Future[None]], cache) + int_or_fut = cache.get(domain, _UNDEF) + # Integration is never subclassed, so we can check for type + if type(int_or_fut) is Integration: # noqa: E721 + return int_or_fut + raise IntegrationNotLoaded(domain) + + async def async_get_integration(hass: HomeAssistant, domain: str) -> Integration: """Get integration.""" integrations_or_excs = await async_get_integrations(hass, [domain]) @@ -970,6 +986,15 @@ def __init__(self, domain: str) -> None: self.domain = domain +class IntegrationNotLoaded(LoaderError): + """Raised when a component is not loaded.""" + + def __init__(self, domain: str) -> None: + """Initialize a component not found error.""" + super().__init__(f"Integration '{domain}' not loaded.") + self.domain = domain + + class CircularDependency(LoaderError): """Raised when a circular dependency is found when resolving components.""" diff --git a/tests/test_loader.py b/tests/test_loader.py index 6e62be08f66a6e..b62e25b79e3a2c 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -150,10 +150,17 @@ async def test_custom_integration_version_not_valid( async def test_get_integration(hass: HomeAssistant) -> None: """Test resolving integration.""" + with pytest.raises(loader.IntegrationNotLoaded): + loader.async_get_loaded_integration(hass, "hue") + integration = await loader.async_get_integration(hass, "hue") assert hue == integration.get_component() assert hue_light == integration.get_platform("light") + integration = loader.async_get_loaded_integration(hass, "hue") + assert hue == integration.get_component() + assert hue_light == integration.get_platform("light") + async def test_get_integration_exceptions(hass: HomeAssistant) -> None: """Test resolving integration.""" From 3b6811dab67389695caa11540e4caa0e8df27085 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 15:59:18 +0300 Subject: [PATCH 079/640] Use `CONF_SALT` correctly in config_flow validation (#99597) --- homeassistant/components/simplepush/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/simplepush/config_flow.py b/homeassistant/components/simplepush/config_flow.py index 702be4391e4e40..d87f6fa1913190 100644 --- a/homeassistant/components/simplepush/config_flow.py +++ b/homeassistant/components/simplepush/config_flow.py @@ -20,7 +20,7 @@ def validate_input(entry: dict[str, str]) -> dict[str, str] | None: send( key=entry[CONF_DEVICE_KEY], password=entry[CONF_PASSWORD], - salt=entry[CONF_PASSWORD], + salt=entry[CONF_SALT], title="HA test", message="Message delivered successfully", ) From cab0bde37b8f2ae0b2b575a7996d954b16377693 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:05:33 +0200 Subject: [PATCH 080/640] Use shorthand attributes in Lyric (#99593) --- homeassistant/components/lyric/climate.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index ef662d061e80c0..1522f167a4a970 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -139,6 +139,13 @@ class LyricClimate(LyricDeviceEntity, ClimateEntity): entity_description: ClimateEntityDescription _attr_name = None + _attr_preset_modes = [ + PRESET_NO_HOLD, + PRESET_HOLD_UNTIL, + PRESET_PERMANENT_HOLD, + PRESET_TEMPORARY_HOLD, + PRESET_VACATION_HOLD, + ] def __init__( self, @@ -245,17 +252,6 @@ def preset_mode(self) -> str | None: """Return current preset mode.""" return self.device.changeableValues.thermostatSetpointStatus - @property - def preset_modes(self) -> list[str] | None: - """Return preset modes.""" - return [ - PRESET_NO_HOLD, - PRESET_HOLD_UNTIL, - PRESET_PERMANENT_HOLD, - PRESET_TEMPORARY_HOLD, - PRESET_VACATION_HOLD, - ] - @property def min_temp(self) -> float: """Identify min_temp in Lyric API or defaults if not available.""" From f1bb7c25db1dcc2f06717add2772d6989bf18750 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:18:22 +0200 Subject: [PATCH 081/640] Use shorthand attributes in Motion eye (#99596) --- homeassistant/components/motioneye/camera.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/motioneye/camera.py b/homeassistant/components/motioneye/camera.py index 683308e081cedb..fd3f0ec86c0298 100644 --- a/homeassistant/components/motioneye/camera.py +++ b/homeassistant/components/motioneye/camera.py @@ -143,6 +143,10 @@ def camera_add(camera: dict[str, Any]) -> None: class MotionEyeMjpegCamera(MotionEyeEntity, MjpegCamera): """motionEye mjpeg camera.""" + _attr_brand = MOTIONEYE_MANUFACTURER + # motionEye cameras are always streaming or unavailable. + _attr_is_streaming = True + def __init__( self, config_entry_id: str, @@ -158,9 +162,6 @@ def __init__( self._surveillance_password = password self._motion_detection_enabled: bool = camera.get(KEY_MOTION_DETECTION, False) - # motionEye cameras are always streaming or unavailable. - self._attr_is_streaming = True - MotionEyeEntity.__init__( self, config_entry_id, @@ -249,11 +250,6 @@ def _handle_coordinator_update(self) -> None: ) super()._handle_coordinator_update() - @property - def brand(self) -> str: - """Return the camera brand.""" - return MOTIONEYE_MANUFACTURER - @property def motion_detection_enabled(self) -> bool: """Return the camera motion detection status.""" From 8dc05894a8c45c6a6362520e5b2e1dd0392a554a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:18:48 +0200 Subject: [PATCH 082/640] Use shorthand attributes in Nanoleaf (#99601) --- homeassistant/components/nanoleaf/light.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nanoleaf/light.py b/homeassistant/components/nanoleaf/light.py index f0425594763e12..dc251ac1e5d45a 100644 --- a/homeassistant/components/nanoleaf/light.py +++ b/homeassistant/components/nanoleaf/light.py @@ -47,6 +47,7 @@ class NanoleafLight(NanoleafEntity, LightEntity): _attr_supported_color_modes = {ColorMode.COLOR_TEMP, ColorMode.HS} _attr_supported_features = LightEntityFeature.EFFECT | LightEntityFeature.TRANSITION _attr_name = None + _attr_icon = "mdi:triangle-outline" def __init__( self, nanoleaf: Nanoleaf, coordinator: DataUpdateCoordinator[None] @@ -83,11 +84,6 @@ def effect_list(self) -> list[str]: """Return the list of supported effects.""" return self._nanoleaf.effects_list - @property - def icon(self) -> str: - """Return the icon to use in the frontend, if any.""" - return "mdi:triangle-outline" - @property def is_on(self) -> bool: """Return true if light is on.""" From c225ee89d6db56744e8d7c7550fec093188ef729 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 4 Sep 2023 09:26:14 -0400 Subject: [PATCH 083/640] Bump ZHA dependencies (#99561) --- 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 809b576defae69..7352487a318732 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,12 +21,12 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.1", + "bellows==0.36.2", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", "zigpy-deconz==0.21.0", - "zigpy==0.57.0", + "zigpy==0.57.1", "zigpy-xbee==0.18.1", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", diff --git a/requirements_all.txt b/requirements_all.txt index f2369f1cf7f139..7cbd80a163c525 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2795,7 +2795,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zoneminder zm-py==0.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c3adc7ddf98d6c..b295f0dc7e86ab 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.1 +bellows==0.36.2 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 @@ -2062,7 +2062,7 @@ zigpy-zigate==0.11.0 zigpy-znp==0.11.4 # homeassistant.components.zha -zigpy==0.57.0 +zigpy==0.57.1 # homeassistant.components.zwave_js zwave-js-server-python==0.51.0 From 799d0e591c763f7b961b9c1481894c0ed5ff761b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:29:30 +0200 Subject: [PATCH 084/640] Remove unneeded name property from Logi Circle (#99604) --- homeassistant/components/logi_circle/camera.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 77c0f2f24c8def..5c27d2a08ae4fb 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -122,11 +122,6 @@ def unique_id(self): """Return a unique ID.""" return self._id - @property - def name(self): - """Return the name of this camera.""" - return self._name - @property def device_info(self) -> DeviceInfo: """Return information about the device.""" From 29664d04d069fcb80ca3910572c609add6992830 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 15:31:33 +0200 Subject: [PATCH 085/640] Use shorthand attributes in Mutesync (#99600) --- .../components/mutesync/binary_sensor.py | 25 +++++++------------ 1 file changed, 9 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/mutesync/binary_sensor.py b/homeassistant/components/mutesync/binary_sensor.py index 444643d5333acb..910f91fc4c60fc 100644 --- a/homeassistant/components/mutesync/binary_sensor.py +++ b/homeassistant/components/mutesync/binary_sensor.py @@ -36,24 +36,17 @@ def __init__(self, coordinator, sensor_type): super().__init__(coordinator) self._sensor_type = sensor_type self._attr_translation_key = sensor_type - - @property - def unique_id(self): - """Return the unique ID of the sensor.""" - return f"{self.coordinator.data['user-id']}-{self._sensor_type}" - - @property - def is_on(self): - """Return the state of the sensor.""" - return self.coordinator.data[self._sensor_type] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info of the sensor.""" - return DeviceInfo( + user_id = coordinator.data["user-id"] + self._attr_unique_id = f"{user_id}-{sensor_type}" + self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.data["user-id"])}, + identifiers={(DOMAIN, user_id)}, manufacturer="mütesync", model="mutesync app", name="mutesync", ) + + @property + def is_on(self): + """Return the state of the sensor.""" + return self.coordinator.data[self._sensor_type] From aa943b7103c0fc4847fbc2db7190f78c3b5e4eeb Mon Sep 17 00:00:00 2001 From: Duco Sebel <74970928+DCSBL@users.noreply.github.com> Date: Mon, 4 Sep 2023 16:21:55 +0200 Subject: [PATCH 086/640] Bumb python-homewizard-energy to 2.1.0 (#99598) --- 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 36b9631c801b32..8930ec90ebff3e 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.0.2"], + "requirements": ["python-homewizard-energy==2.1.0"], "zeroconf": ["_hwenergy._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 7cbd80a163c525..99d685b7aa4784 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2109,7 +2109,7 @@ python-gc100==1.0.3a0 python-gitlab==1.6.0 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.hp_ilo python-hpilo==4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b295f0dc7e86ab..c6a6a19a8091eb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1556,7 +1556,7 @@ python-ecobee-api==0.2.14 python-fullykiosk==0.0.12 # homeassistant.components.homewizard -python-homewizard-energy==2.0.2 +python-homewizard-energy==2.1.0 # homeassistant.components.izone python-izone==1.2.9 From f2e0ff4f0f2126daaf8ba97e5b613729200a818a Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 4 Sep 2023 17:24:20 +0300 Subject: [PATCH 087/640] Bump simplepush api to 2.2.3 (#99599) --- homeassistant/components/simplepush/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/simplepush/manifest.json b/homeassistant/components/simplepush/manifest.json index 25f53a9617ca2a..5b792072f4479d 100644 --- a/homeassistant/components/simplepush/manifest.json +++ b/homeassistant/components/simplepush/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/simplepush", "iot_class": "cloud_polling", "loggers": ["simplepush"], - "requirements": ["simplepush==2.1.1"] + "requirements": ["simplepush==2.2.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 99d685b7aa4784..a0efbb697b2738 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2404,7 +2404,7 @@ shodan==1.28.0 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c6a6a19a8091eb..705d67b73b46fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1761,7 +1761,7 @@ sharkiq==1.0.2 simplehound==0.3 # homeassistant.components.simplepush -simplepush==2.1.1 +simplepush==2.2.3 # homeassistant.components.simplisafe simplisafe-python==2023.08.0 From 22e90a5755ec3bcb99ab83c7e9ce44f9344a3150 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 17:53:33 +0200 Subject: [PATCH 088/640] Remove default state from Nibe (#99611) Remove start state --- homeassistant/components/nibe_heatpump/number.py | 1 - homeassistant/components/nibe_heatpump/switch.py | 1 - 2 files changed, 2 deletions(-) diff --git a/homeassistant/components/nibe_heatpump/number.py b/homeassistant/components/nibe_heatpump/number.py index 79078811881a8a..1b3bc928985505 100644 --- a/homeassistant/components/nibe_heatpump/number.py +++ b/homeassistant/components/nibe_heatpump/number.py @@ -56,7 +56,6 @@ def __init__(self, coordinator: Coordinator, coil: Coil) -> None: self._attr_native_step = 1 / coil.factor self._attr_native_unit_of_measurement = coil.unit - self._attr_native_value = None def _async_read_coil(self, data: CoilData) -> None: if data.value is None: diff --git a/homeassistant/components/nibe_heatpump/switch.py b/homeassistant/components/nibe_heatpump/switch.py index 95d96de9764d0a..16a7ef2b1f569f 100644 --- a/homeassistant/components/nibe_heatpump/switch.py +++ b/homeassistant/components/nibe_heatpump/switch.py @@ -38,7 +38,6 @@ class Switch(CoilEntity, SwitchEntity): def __init__(self, coordinator: Coordinator, coil: Coil) -> None: """Initialize entity.""" super().__init__(coordinator, coil, ENTITY_ID_FORMAT) - self._attr_is_on = None def _async_read_coil(self, data: CoilData) -> None: self._attr_is_on = data.value == "ON" From 2391087836535a96971371573a4529df96672d29 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 18:50:33 +0200 Subject: [PATCH 089/640] Use shorthand attributes in Nest (#99606) --- homeassistant/components/nest/camera.py | 29 +++++-------------------- 1 file changed, 6 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/nest/camera.py b/homeassistant/components/nest/camera.py index 90c4056161e530..c943ea922e9e30 100644 --- a/homeassistant/components/nest/camera.py +++ b/homeassistant/components/nest/camera.py @@ -24,7 +24,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow @@ -68,7 +67,10 @@ def __init__(self, device: Device) -> None: """Initialize the camera.""" super().__init__() self._device = device - self._device_info = NestDeviceInfo(device) + nest_device_info = NestDeviceInfo(device) + self._attr_device_info = nest_device_info.device_info + self._attr_brand = nest_device_info.device_brand + self._attr_model = nest_device_info.device_model self._stream: RtspStream | None = None self._create_stream_url_lock = asyncio.Lock() self._stream_refresh_unsub: Callable[[], None] | None = None @@ -84,33 +86,14 @@ def __init__(self, device: Device) -> None: if StreamingProtocol.RTSP in trait.supported_protocols: self._rtsp_live_stream_trait = trait self.stream_options[CONF_EXTRA_PART_WAIT_TIME] = 3 + # The API "name" field is a unique device identifier. + self._attr_unique_id = f"{self._device.name}-camera" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return self._rtsp_live_stream_trait is not None - @property - def unique_id(self) -> str: - """Return a unique ID.""" - # The API "name" field is a unique device identifier. - return f"{self._device.name}-camera" - - @property - def device_info(self) -> DeviceInfo: - """Return device specific attributes.""" - return self._device_info.device_info - - @property - def brand(self) -> str | None: - """Return the camera brand.""" - return self._device_info.device_brand - - @property - def model(self) -> str | None: - """Return the camera model.""" - return self._device_info.device_model - @property def frontend_stream_type(self) -> StreamType | None: """Return the type of stream supported by this camera.""" From cb5d4ee6fa4f56ecd877938137d1aa75b209edab Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 19:00:19 +0200 Subject: [PATCH 090/640] Use shorthand attributes in Octoprint (#99623) --- homeassistant/components/octoprint/binary_sensor.py | 6 +----- homeassistant/components/octoprint/button.py | 7 +------ homeassistant/components/octoprint/sensor.py | 6 +----- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/octoprint/binary_sensor.py b/homeassistant/components/octoprint/binary_sensor.py index b0e43bd74e0fea..0bc13f66415eda 100644 --- a/homeassistant/components/octoprint/binary_sensor.py +++ b/homeassistant/components/octoprint/binary_sensor.py @@ -52,11 +52,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def is_on(self): diff --git a/homeassistant/components/octoprint/button.py b/homeassistant/components/octoprint/button.py index 578554da5bd1f6..b2c1672b3e4d07 100644 --- a/homeassistant/components/octoprint/button.py +++ b/homeassistant/components/octoprint/button.py @@ -5,7 +5,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -53,11 +52,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {button_type}" self._attr_unique_id = f"{button_type}-{device_id}" - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info @property def available(self) -> bool: diff --git a/homeassistant/components/octoprint/sensor.py b/homeassistant/components/octoprint/sensor.py index 17bea7b8ac56cd..1ea29c2b4e8bcc 100644 --- a/homeassistant/components/octoprint/sensor.py +++ b/homeassistant/components/octoprint/sensor.py @@ -104,11 +104,7 @@ def __init__( self._device_id = device_id self._attr_name = f"OctoPrint {sensor_type}" self._attr_unique_id = f"{sensor_type}-{device_id}" - - @property - def device_info(self): - """Device info.""" - return self.coordinator.device_info + self._attr_device_info = coordinator.device_info class OctoPrintStatusSensor(OctoPrintSensorBase): From 4812b21ffdef6c1224b14c754b1809675256c6bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 19:28:44 +0200 Subject: [PATCH 091/640] Remove slugify from tomorrowio unique id (#99006) --- homeassistant/components/tomorrowio/sensor.py | 89 ++++++++++++------- 1 file changed, 57 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/tomorrowio/sensor.py b/homeassistant/components/tomorrowio/sensor.py index 119a3dfe582732..cd48af8536a2ec 100644 --- a/homeassistant/components/tomorrowio/sensor.py +++ b/homeassistant/components/tomorrowio/sensor.py @@ -35,7 +35,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.util import slugify from homeassistant.util.unit_conversion import DistanceConverter, SpeedConverter from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM @@ -80,6 +79,7 @@ class TomorrowioSensorEntityDescription(SensorEntityDescription): # restrict the type to str. name: str = "" + attribute: str = "" unit_imperial: str | None = None unit_metric: str | None = None multiplication_factor: Callable[[float], float] | float | None = None @@ -110,13 +110,15 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa SENSOR_TYPES = ( TomorrowioSensorEntityDescription( - key=TMRW_ATTR_FEELS_LIKE, + key="feels_like", + attribute=TMRW_ATTR_FEELS_LIKE, name="Feels Like", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_DEW_POINT, + key="dew_point", + attribute=TMRW_ATTR_DEW_POINT, name="Dew Point", icon="mdi:thermometer-water", native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -124,7 +126,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as hPa TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, + key="pressure_surface_level", + attribute=TMRW_ATTR_PRESSURE_SURFACE_LEVEL, name="Pressure (Surface Level)", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, @@ -132,7 +135,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as W/m^2, convert to BTUs/(hr * ft^2) for imperial # https://www.theunitconverter.com/watt-square-meter-to-btu-hour-square-foot-conversion/ TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SOLAR_GHI, + 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, @@ -141,7 +145,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_BASE, + key="cloud_base", + attribute=TMRW_ATTR_CLOUD_BASE, name="Cloud Base", icon="mdi:cloud-arrow-down", unit_imperial=UnitOfLength.MILES, @@ -154,7 +159,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as km, convert to miles for imperial TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_CEILING, + key="cloud_ceiling", + attribute=TMRW_ATTR_CLOUD_CEILING, name="Cloud Ceiling", icon="mdi:cloud-arrow-up", unit_imperial=UnitOfLength.MILES, @@ -166,14 +172,16 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CLOUD_COVER, + 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=TMRW_ATTR_WIND_GUST, + key="wind_gust", + attribute=TMRW_ATTR_WIND_GUST, name="Wind Gust", icon="mdi:weather-windy", unit_imperial=UnitOfSpeed.MILES_PER_HOUR, @@ -183,7 +191,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PRECIPITATION_TYPE, + key="precipitation_type", + attribute=TMRW_ATTR_PRECIPITATION_TYPE, name="Precipitation Type", value_map=PrecipitationType, translation_key="precipitation_type", @@ -192,20 +201,23 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Ozone is 48 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_OZONE, + 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, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_25, + 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, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_PARTICULATE_MATTER_10, + 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, @@ -213,7 +225,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Nitrogen Dioxide is 46.01 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_NITROGEN_DIOXIDE, + 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), @@ -221,7 +234,8 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa ), # Data comes in as ppb, convert to ppm TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CARBON_MONOXIDE, + key="carbon_monoxide", + attribute=TMRW_ATTR_CARBON_MONOXIDE, name="Carbon Monoxide", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, multiplication_factor=1 / 1000, @@ -230,82 +244,95 @@ def convert_ppb_to_ugm3(molecular_weight: int | float) -> Callable[[float], floa # Data comes in as ppb, convert to µg/m^3 # Molecular weight of Sulphur Dioxide is 64.07 TomorrowioSensorEntityDescription( - key=TMRW_ATTR_SULPHUR_DIOXIDE, + 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, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_AQI, + key="us_epa_air_quality_index", + attribute=TMRW_ATTR_EPA_AQI, name="US EPA Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, + key="us_epa_primary_pollutant", + attribute=TMRW_ATTR_EPA_PRIMARY_POLLUTANT, name="US EPA Primary Pollutant", value_map=PrimaryPollutantType, translation_key="primary_pollutant", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_EPA_HEALTH_CONCERN, + key="us_epa_health_concern", + attribute=TMRW_ATTR_EPA_HEALTH_CONCERN, name="US EPA Health Concern", value_map=HealthConcernType, translation_key="health_concern", icon="mdi:hospital", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_AQI, + key="china_mep_air_quality_index", + attribute=TMRW_ATTR_CHINA_AQI, name="China MEP Air Quality Index", device_class=SensorDeviceClass.AQI, ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_CHINA_PRIMARY_POLLUTANT, + 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=TMRW_ATTR_CHINA_HEALTH_CONCERN, + 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=TMRW_ATTR_POLLEN_TREE, + key="tree_pollen_index", + attribute=TMRW_ATTR_POLLEN_TREE, name="Tree Pollen Index", icon="mdi:tree", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_POLLEN_WEED, + 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=TMRW_ATTR_POLLEN_GRASS, + key="grass_pollen_index", + attribute=TMRW_ATTR_POLLEN_GRASS, name="Grass Pollen Index", icon="mdi:grass", value_map=PollenIndex, translation_key="pollen_index", ), TomorrowioSensorEntityDescription( - TMRW_ATTR_FIRE_INDEX, + key="fire_index", + attribute=TMRW_ATTR_FIRE_INDEX, name="Fire Index", icon="mdi:fire", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_INDEX, + key="uv_index", + attribute=TMRW_ATTR_UV_INDEX, name="UV Index", state_class=SensorStateClass.MEASUREMENT, icon="mdi:sun-wireless", ), TomorrowioSensorEntityDescription( - key=TMRW_ATTR_UV_HEALTH_CONCERN, + key="uv_radiation_health_concern", + attribute=TMRW_ATTR_UV_HEALTH_CONCERN, name="UV Radiation Health Concern", value_map=UVDescription, translation_key="uv_index", @@ -356,9 +383,7 @@ def __init__( 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}_{slugify(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 if hass.config.units is US_CUSTOMARY_SYSTEM: @@ -403,6 +428,6 @@ class TomorrowioSensorEntity(BaseTomorrowioSensorEntity): @property def _state(self) -> int | float | None: """Return the raw state.""" - val = self._get_current_property(self.entity_description.key) + val = self._get_current_property(self.entity_description.attribute) assert not isinstance(val, str) return val From e57ed26896a138d0bd444ba89ec2854248e01ed0 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Mon, 4 Sep 2023 13:51:33 -0400 Subject: [PATCH 092/640] Bump pyschlage to 2023.9.0 (#99624) --- 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 25316004c58c9c..fb4ccc81deed26 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.8.1"] + "requirements": ["pyschlage==2023.9.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a0efbb697b2738..b091bfccc488d5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1990,7 +1990,7 @@ pysabnzbd==1.1.1 pysaj==0.0.16 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 705d67b73b46fb..7923a625e5f041 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1482,7 +1482,7 @@ pyrympro==0.0.7 pysabnzbd==1.1.1 # homeassistant.components.schlage -pyschlage==2023.8.1 +pyschlage==2023.9.0 # homeassistant.components.sensibo pysensibo==1.0.33 From 26fd36dc4c10755f267662b7ec9d503db1f1a83c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 20:10:16 +0200 Subject: [PATCH 093/640] Revert "Deprecate timer start optional duration parameter" (#99613) Revert "Deprecate timer start optional duration parameter (#93471)" This reverts commit 2ce5b08fc36e77a2594a39040e5440d2ca01dff8. --- homeassistant/components/timer/__init__.py | 13 ------------- homeassistant/components/timer/strings.json | 13 ------------- tests/components/timer/test_init.py | 16 ++-------------- 3 files changed, 2 insertions(+), 40 deletions(-) diff --git a/homeassistant/components/timer/__init__.py b/homeassistant/components/timer/__init__.py index 1bc8eb8fd5ef70..228e2071b4a194 100644 --- a/homeassistant/components/timer/__init__.py +++ b/homeassistant/components/timer/__init__.py @@ -22,7 +22,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -304,18 +303,6 @@ async def async_added_to_hass(self): @callback def async_start(self, duration: timedelta | None = None): """Start a timer.""" - if duration: - async_create_issue( - self.hass, - DOMAIN, - "deprecated_duration_in_start", - breaks_in_ha_version="2024.3.0", - is_fixable=True, - is_persistent=True, - severity=IssueSeverity.WARNING, - translation_key="deprecated_duration_in_start", - ) - if self._listener: self._listener() self._listener = None diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index c85a9f4c55e156..56cb46d26b4583 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -63,18 +63,5 @@ } } } - }, - "issues": { - "deprecated_duration_in_start": { - "title": "The timer start service duration parameter is being removed", - "fix_flow": { - "step": { - "confirm": { - "title": "[%key:component::timer::issues::deprecated_duration_in_start::title%]", - "description": "The timer service `timer.start` optional duration parameter is being removed and use of it has been detected. To change the duration please create a new timer.\n\nPlease remove the use of the `duration` parameter in the `timer.start` service in your automations and scripts and select **submit** to close this issue." - } - } - } - } } } diff --git a/tests/components/timer/test_init.py b/tests/components/timer/test_init.py index 7bc2df87f35702..eabc5e04e0bce5 100644 --- a/tests/components/timer/test_init.py +++ b/tests/components/timer/test_init.py @@ -46,11 +46,7 @@ ) from homeassistant.core import Context, CoreState, HomeAssistant, State from homeassistant.exceptions import HomeAssistantError, Unauthorized -from homeassistant.helpers import ( - config_validation as cv, - entity_registry as er, - issue_registry as ir, -) +from homeassistant.helpers import config_validation as cv, entity_registry as er from homeassistant.helpers.restore_state import StoredState, async_get from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -270,9 +266,7 @@ def fake_event_listener(event): @pytest.mark.freeze_time("2023-06-05 17:47:50") -async def test_start_service( - hass: HomeAssistant, issue_registry: ir.IssueRegistry -) -> None: +async def test_start_service(hass: HomeAssistant) -> None: """Test the start/stop service.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {"test1": {CONF_DURATION: 10}}}) @@ -317,12 +311,6 @@ async def test_start_service( blocking=True, ) await hass.async_block_till_done() - - # Ensure an issue is raised for the use of this deprecated service - assert issue_registry.async_get_issue( - domain=DOMAIN, issue_id="deprecated_duration_in_start" - ) - state = hass.states.get("timer.test1") assert state assert state.state == STATUS_ACTIVE From 7e36da4cc0b2bf1ee962f3b8e96eb1ef4fe6a1ea Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 20:17:30 +0200 Subject: [PATCH 094/640] Small cleanup of WS command render_template (#99562) --- homeassistant/components/websocket_api/commands.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index c6564967a394a2..84c7567a40eb53 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -516,7 +516,6 @@ async def handle_render_template( template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") - info = None if timeout: try: @@ -540,7 +539,6 @@ def _template_listener( event: EventType[EventStateChangedData] | None, updates: list[TrackTemplateResult], ) -> None: - nonlocal info track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): @@ -549,7 +547,7 @@ def _template_listener( connection.send_message( messages.event_message( - msg["id"], {"result": result, "listeners": info.listeners} # type: ignore[attr-defined] + msg["id"], {"result": result, "listeners": info.listeners} ) ) From e5ebba07532ed87d3695f3a094390ab8548fb996 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 13:19:10 -0500 Subject: [PATCH 095/640] Fix module check in _async_get_flow_handler (#99509) We should have been checking for the module in hass.data[DATA_COMPONENTS] and not hass.config.components as the check was ineffective if there were no existing integrations instances for the domain which is the case for discovery or when the integration is ignored --- homeassistant/config_entries.py | 4 +++- homeassistant/loader.py | 5 +++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7900c6b62a4002..f627b804989f9f 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -2081,7 +2081,9 @@ async def _async_get_flow_handler( """Get a flow handler for specified domain.""" # First check if there is a handler registered for the domain - if domain in hass.config.components and (handler := HANDLERS.get(domain)): + if loader.is_component_module_loaded(hass, f"{domain}.config_flow") and ( + handler := HANDLERS.get(domain) + ): return handler await _load_integration(hass, domain, hass_config) diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 8906cefb24197e..9d4d6e880f89a5 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -1187,3 +1187,8 @@ def _lookup_path(hass: HomeAssistant) -> list[str]: if hass.config.safe_mode: return [PACKAGE_BUILTIN] return [PACKAGE_CUSTOM_COMPONENTS, PACKAGE_BUILTIN] + + +def is_component_module_loaded(hass: HomeAssistant, module: str) -> bool: + """Test if a component module is loaded.""" + return module in hass.data[DATA_COMPONENTS] From a77f1cbd9e19c5758c8bda9536286afd26c4c10e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Mon, 4 Sep 2023 20:23:46 +0200 Subject: [PATCH 096/640] Mark AVM Fritz!Smarthome as Gold integration (#97086) set quality scale to gold --- homeassistant/components/fritzbox/manifest.json | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/fritzbox/manifest.json b/homeassistant/components/fritzbox/manifest.json index 35b78e91f81669..fdf38d88439ffb 100644 --- a/homeassistant/components/fritzbox/manifest.json +++ b/homeassistant/components/fritzbox/manifest.json @@ -7,6 +7,7 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyfritzhome"], + "quality_scale": "gold", "requirements": ["pyfritzhome==0.6.9"], "ssdp": [ { From c1cfded355142d358e4d8274743cfd7954553841 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 4 Sep 2023 20:44:20 +0200 Subject: [PATCH 097/640] Update frontend to 20230904.0 (#99636) --- 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 3b46f568d3eccb..156adfa73d2152 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==20230901.0"] + "requirements": ["home-assistant-frontend==20230904.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a1d4a0c7bf9181..cf17cb9b9136f9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index b091bfccc488d5..4996ca70ea0092 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7923a625e5f041..522f891f7ba67c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230901.0 +home-assistant-frontend==20230904.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From bc1575a47783acf40cbf6ccc835d1fc2e80ec584 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 22:18:36 +0200 Subject: [PATCH 098/640] Move variables out of constructor in Nobo hub (#99617) --- homeassistant/components/nobo_hub/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/nobo_hub/climate.py b/homeassistant/components/nobo_hub/climate.py index e3cfa04802c2a0..7041d097f3e0a4 100644 --- a/homeassistant/components/nobo_hub/climate.py +++ b/homeassistant/components/nobo_hub/climate.py @@ -74,6 +74,8 @@ class NoboZone(ClimateEntity): _attr_max_temp = MAX_TEMPERATURE _attr_min_temp = MIN_TEMPERATURE _attr_precision = PRECISION_TENTHS + _attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] + _attr_hvac_mode = HVACMode.AUTO _attr_preset_modes = PRESET_MODES _attr_supported_features = SUPPORT_FLAGS _attr_temperature_unit = UnitOfTemperature.CELSIUS @@ -85,8 +87,6 @@ def __init__(self, zone_id, hub: nobo, override_type) -> None: self._id = zone_id self._nobo = hub self._attr_unique_id = f"{hub.hub_serial}:{zone_id}" - self._attr_hvac_mode = HVACMode.AUTO - self._attr_hvac_modes = [HVACMode.HEAT, HVACMode.AUTO] self._override_type = override_type self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, f"{hub.hub_serial}:{zone_id}")}, From de73cafc8bba413c0f6d496da7148b604031da8f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 4 Sep 2023 22:19:40 +0200 Subject: [PATCH 099/640] Small cleanup of TemplateEnvironment (#99571) * Small cleanup of TemplateEnvironment * Fix typo --- homeassistant/helpers/template.py | 57 +++++++++++++++++-------------- 1 file changed, 32 insertions(+), 25 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 40d64ba37aead7..b5a6a45e97fa6e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -492,7 +492,7 @@ def _env(self) -> TemplateEnvironment: if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( self.hass, - self._limited, # type: ignore[no-untyped-call] + self._limited, self._strict, ) return ret @@ -2276,7 +2276,12 @@ def get_source( class TemplateEnvironment(ImmutableSandboxedEnvironment): """The Home Assistant template environment.""" - def __init__(self, hass, limited=False, strict=False): + def __init__( + self, + hass: HomeAssistant | None, + limited: bool | None = False, + strict: bool | None = False, + ) -> None: """Initialise template environment.""" undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] if not strict: @@ -2381,6 +2386,10 @@ def __init__(self, hass, limited=False, strict=False): # can be discarded, we only need to get at the hass object. def hassfunction( func: Callable[Concatenate[HomeAssistant, _P], _R], + jinja_context: Callable[ + [Callable[Concatenate[Any, _P], _R]], + Callable[Concatenate[Any, _P], _R], + ] = pass_context, ) -> Callable[Concatenate[Any, _P], _R]: """Wrap function that depend on hass.""" @@ -2388,42 +2397,40 @@ def hassfunction( def wrapper(_: Any, *args: _P.args, **kwargs: _P.kwargs) -> _R: return func(hass, *args, **kwargs) - return pass_context(wrapper) + return jinja_context(wrapper) self.globals["device_entities"] = hassfunction(device_entities) - self.filters["device_entities"] = pass_context(self.globals["device_entities"]) + self.filters["device_entities"] = self.globals["device_entities"] self.globals["device_attr"] = hassfunction(device_attr) - self.filters["device_attr"] = pass_context(self.globals["device_attr"]) + self.filters["device_attr"] = self.globals["device_attr"] self.globals["is_device_attr"] = hassfunction(is_device_attr) - self.tests["is_device_attr"] = pass_eval_context(self.globals["is_device_attr"]) + self.tests["is_device_attr"] = hassfunction(is_device_attr, pass_eval_context) self.globals["config_entry_id"] = hassfunction(config_entry_id) - self.filters["config_entry_id"] = pass_context(self.globals["config_entry_id"]) + self.filters["config_entry_id"] = self.globals["config_entry_id"] self.globals["device_id"] = hassfunction(device_id) - self.filters["device_id"] = pass_context(self.globals["device_id"]) + self.filters["device_id"] = self.globals["device_id"] self.globals["areas"] = hassfunction(areas) - self.filters["areas"] = pass_context(self.globals["areas"]) + self.filters["areas"] = self.globals["areas"] self.globals["area_id"] = hassfunction(area_id) - self.filters["area_id"] = pass_context(self.globals["area_id"]) + self.filters["area_id"] = self.globals["area_id"] self.globals["area_name"] = hassfunction(area_name) - self.filters["area_name"] = pass_context(self.globals["area_name"]) + self.filters["area_name"] = self.globals["area_name"] self.globals["area_entities"] = hassfunction(area_entities) - self.filters["area_entities"] = pass_context(self.globals["area_entities"]) + self.filters["area_entities"] = self.globals["area_entities"] self.globals["area_devices"] = hassfunction(area_devices) - self.filters["area_devices"] = pass_context(self.globals["area_devices"]) + self.filters["area_devices"] = self.globals["area_devices"] self.globals["integration_entities"] = hassfunction(integration_entities) - self.filters["integration_entities"] = pass_context( - self.globals["integration_entities"] - ) + self.filters["integration_entities"] = self.globals["integration_entities"] if limited: # Only device_entities is available to limited templates, mark other @@ -2479,25 +2486,25 @@ def warn_unsupported(*args: Any, **kwargs: Any) -> NoReturn: return self.globals["expand"] = hassfunction(expand) - self.filters["expand"] = pass_context(self.globals["expand"]) + self.filters["expand"] = self.globals["expand"] self.globals["closest"] = hassfunction(closest) - self.filters["closest"] = pass_context(hassfunction(closest_filter)) + self.filters["closest"] = hassfunction(closest_filter) self.globals["distance"] = hassfunction(distance) self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity) - self.tests["is_hidden_entity"] = pass_eval_context( - self.globals["is_hidden_entity"] + self.tests["is_hidden_entity"] = hassfunction( + is_hidden_entity, pass_eval_context ) self.globals["is_state"] = hassfunction(is_state) - self.tests["is_state"] = pass_eval_context(self.globals["is_state"]) + self.tests["is_state"] = hassfunction(is_state, pass_eval_context) self.globals["is_state_attr"] = hassfunction(is_state_attr) - self.tests["is_state_attr"] = pass_eval_context(self.globals["is_state_attr"]) + self.tests["is_state_attr"] = hassfunction(is_state_attr, pass_eval_context) self.globals["state_attr"] = hassfunction(state_attr) self.filters["state_attr"] = self.globals["state_attr"] self.globals["states"] = AllStates(hass) self.filters["states"] = self.globals["states"] self.globals["has_value"] = hassfunction(has_value) - self.filters["has_value"] = pass_context(self.globals["has_value"]) - self.tests["has_value"] = pass_eval_context(self.globals["has_value"]) + self.filters["has_value"] = self.globals["has_value"] + self.tests["has_value"] = hassfunction(has_value, pass_eval_context) self.globals["utcnow"] = hassfunction(utcnow) self.globals["now"] = hassfunction(now) self.globals["relative_time"] = hassfunction(relative_time) @@ -2575,4 +2582,4 @@ def compile( return cached -_NO_HASS_ENV = TemplateEnvironment(None) # type: ignore[no-untyped-call] +_NO_HASS_ENV = TemplateEnvironment(None) From b8f35fb5777cb2fcc0c67e26c39246e9937d50c4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 22:31:53 +0200 Subject: [PATCH 100/640] Fix missing unique id in SQL (#99641) --- homeassistant/components/sql/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sql/sensor.py b/homeassistant/components/sql/sensor.py index f4f44d4f9a4fb2..3fdc6b2c0794aa 100644 --- a/homeassistant/components/sql/sensor.py +++ b/homeassistant/components/sql/sensor.py @@ -123,7 +123,7 @@ async def async_setup_entry( value_template.hass = hass name_template = Template(name, hass) - trigger_entity_config = {CONF_NAME: name_template} + trigger_entity_config = {CONF_NAME: name_template, CONF_UNIQUE_ID: entry.entry_id} for key in TRIGGER_ENTITY_OPTIONS: if key not in entry.options: continue From 216a174cba645d5866c34af306000f052b76f1d4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 4 Sep 2023 22:35:58 +0200 Subject: [PATCH 101/640] Move variables out of constructor in nightscout (#99612) * Move variables out of constructor in nightscout * Update homeassistant/components/nightscout/sensor.py Co-authored-by: G Johansson --------- Co-authored-by: G Johansson --- homeassistant/components/nightscout/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/nightscout/sensor.py b/homeassistant/components/nightscout/sensor.py index 795e7b17a1647a..f60c70cc67ce63 100644 --- a/homeassistant/components/nightscout/sensor.py +++ b/homeassistant/components/nightscout/sensor.py @@ -37,15 +37,15 @@ async def async_setup_entry( class NightscoutSensor(SensorEntity): """Implementation of a Nightscout sensor.""" + _attr_native_unit_of_measurement = "mg/dL" + _attr_icon = "mdi:cloud-question" + def __init__(self, api: NightscoutAPI, name, unique_id) -> None: """Initialize the Nightscout sensor.""" self.api = api self._attr_unique_id = unique_id self._attr_name = name self._attr_extra_state_attributes: dict[str, Any] = {} - self._attr_native_unit_of_measurement = "mg/dL" - self._attr_icon = "mdi:cloud-question" - self._attr_available = False async def async_update(self) -> None: """Fetch the latest data from Nightscout REST API and update the state.""" From 47c20495bd1be2fc5f8e23cb58d9d58b3c4cfdd2 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 Sep 2023 22:46:19 +0200 Subject: [PATCH 102/640] Fix not stripping no device class in template helper binary sensor (#99640) Strip none template helper binary sensor --- homeassistant/components/template/config_flow.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index b2ccddedad8ba0..ccc06989c7116d 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -208,6 +208,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 all domaines: Set template type. """ @@ -217,8 +218,9 @@ async def _validate_user_input( user_input: dict[str, Any], ) -> dict[str, Any]: """Add template type to user input.""" - if template_type == Platform.SENSOR: + 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) return {"template_type": template_type} | user_input From d2a52230ff4e1863b7e867196e6f7db21b217c42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 15:51:19 -0500 Subject: [PATCH 103/640] Speed up responding to states being polled via API (#99621) * Speed up responding to states being polled via API Switch to using `as_dict_json` to avoid serializing states over and over when the states api is polled since the mobile app is already building the cache as it also polls the states via the websocket_api * Speed up responding to states being polled via API Switch to using `as_dict_json` to avoid serializing states over and over when the states api is polled since the mobile app is already building the cache as it also polls the states via the websocket_api * fix json * cover --- homeassistant/components/api/__init__.py | 29 ++++++++++++++++-------- tests/components/api/test_init.py | 4 +++- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index b427341546e5d1..10cf63b701dd46 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -14,6 +14,7 @@ from homeassistant.bootstrap import DATA_LOGGING from homeassistant.components.http import HomeAssistantView, require_admin from homeassistant.const import ( + CONTENT_TYPE_JSON, EVENT_HOMEASSISTANT_STOP, MATCH_ALL, URL_API, @@ -195,15 +196,19 @@ def get(self, request: web.Request) -> web.Response: user: User = request["hass_user"] hass: HomeAssistant = request.app["hass"] if user.is_admin: - return self.json([state.as_dict() for state in hass.states.async_all()]) - entity_perm = user.permissions.check_entity - return self.json( - [ - state.as_dict() + states = (state.as_dict_json() for state in hass.states.async_all()) + else: + entity_perm = user.permissions.check_entity + states = ( + state.as_dict_json() for state in hass.states.async_all() if entity_perm(state.entity_id, "read") - ] + ) + response = web.Response( + body=f'[{",".join(states)}]', content_type=CONTENT_TYPE_JSON ) + response.enable_compression() + return response class APIEntityStateView(HomeAssistantView): @@ -213,14 +218,18 @@ class APIEntityStateView(HomeAssistantView): name = "api:entity-state" @ha.callback - def get(self, request, entity_id): + def get(self, request: web.Request, entity_id: str) -> web.Response: """Retrieve state of entity.""" - user = request["hass_user"] + user: User = request["hass_user"] + hass: HomeAssistant = request.app["hass"] if not user.permissions.check_entity(entity_id, POLICY_READ): raise Unauthorized(entity_id=entity_id) - if state := request.app["hass"].states.get(entity_id): - return self.json(state) + if state := hass.states.get(entity_id): + return web.Response( + body=state.as_dict_json(), + content_type=CONTENT_TYPE_JSON, + ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) async def post(self, request, entity_id): diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index f61988eff5a270..38528b335b0b3f 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -575,11 +575,13 @@ async def test_states( ) -> None: """Test fetching all states as admin.""" hass.states.async_set("test.entity", "hello") + hass.states.async_set("test.entity2", "hello") resp = await mock_api_client.get(const.URL_API_STATES) assert resp.status == HTTPStatus.OK json = await resp.json() - assert len(json) == 1 + assert len(json) == 2 assert json[0]["entity_id"] == "test.entity" + assert json[1]["entity_id"] == "test.entity2" async def test_states_view_filters( From 0b383067ef819724296bd999688904a7af0722a7 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Mon, 4 Sep 2023 22:51:57 +0200 Subject: [PATCH 104/640] Move non legacy stt models out from legacy module (#99582) --- homeassistant/components/stt/__init__.py | 3 +- homeassistant/components/stt/legacy.py | 29 +------------------ homeassistant/components/stt/models.py | 37 ++++++++++++++++++++++++ 3 files changed, 39 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/stt/models.py diff --git a/homeassistant/components/stt/__init__.py b/homeassistant/components/stt/__init__.py index 679f9b29e41430..b1730a09357dd3 100644 --- a/homeassistant/components/stt/__init__.py +++ b/homeassistant/components/stt/__init__.py @@ -40,12 +40,11 @@ ) from .legacy import ( Provider, - SpeechMetadata, - SpeechResult, async_default_provider, async_get_provider, async_setup_legacy, ) +from .models import SpeechMetadata, SpeechResult __all__ = [ "async_get_provider", diff --git a/homeassistant/components/stt/legacy.py b/homeassistant/components/stt/legacy.py index f14eed467db2e7..862f59d5f6d97f 100644 --- a/homeassistant/components/stt/legacy.py +++ b/homeassistant/components/stt/legacy.py @@ -3,7 +3,6 @@ from abc import ABC, abstractmethod from collections.abc import AsyncIterable, Coroutine -from dataclasses import dataclass import logging from typing import Any @@ -20,8 +19,8 @@ AudioCodecs, AudioFormats, AudioSampleRates, - SpeechResultState, ) +from .models import SpeechMetadata, SpeechResult _LOGGER = logging.getLogger(__name__) @@ -88,32 +87,6 @@ async def async_platform_discovered(platform, info): ] -@dataclass -class SpeechMetadata: - """Metadata of audio stream.""" - - language: str - format: AudioFormats - codec: AudioCodecs - bit_rate: AudioBitRates - sample_rate: AudioSampleRates - channel: AudioChannels - - def __post_init__(self) -> None: - """Finish initializing the metadata.""" - self.bit_rate = AudioBitRates(int(self.bit_rate)) - self.sample_rate = AudioSampleRates(int(self.sample_rate)) - self.channel = AudioChannels(int(self.channel)) - - -@dataclass -class SpeechResult: - """Result of audio Speech.""" - - text: str | None - result: SpeechResultState - - class Provider(ABC): """Represent a single STT provider.""" diff --git a/homeassistant/components/stt/models.py b/homeassistant/components/stt/models.py new file mode 100644 index 00000000000000..45322e2da079fc --- /dev/null +++ b/homeassistant/components/stt/models.py @@ -0,0 +1,37 @@ +"""Speech-to-text data models.""" +from dataclasses import dataclass + +from .const import ( + AudioBitRates, + AudioChannels, + AudioCodecs, + AudioFormats, + AudioSampleRates, + SpeechResultState, +) + + +@dataclass +class SpeechMetadata: + """Metadata of audio stream.""" + + language: str + format: AudioFormats + codec: AudioCodecs + bit_rate: AudioBitRates + sample_rate: AudioSampleRates + channel: AudioChannels + + def __post_init__(self) -> None: + """Finish initializing the metadata.""" + self.bit_rate = AudioBitRates(int(self.bit_rate)) + self.sample_rate = AudioSampleRates(int(self.sample_rate)) + self.channel = AudioChannels(int(self.channel)) + + +@dataclass +class SpeechResult: + """Result of audio Speech.""" + + text: str | None + result: SpeechResultState From 63273a307a04a0bb666de6ad81f49fa45c9c4c26 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:42:05 -0500 Subject: [PATCH 105/640] Fix Bluetooth passive update processor dispatching updates to unchanged entities (#99527) * Fix passive update processor dispatching updates to unchanged entities * adjust tests * coverage * fix * Update homeassistant/components/bluetooth/update_coordinator.py --- .../bluetooth/passive_update_coordinator.py | 1 + .../bluetooth/passive_update_processor.py | 35 +++++++++++++++---- .../bluetooth/update_coordinator.py | 14 ++------ .../bluetooth/test_active_update_processor.py | 10 +++--- .../test_passive_update_processor.py | 28 +++++++++++++++ 5 files changed, 65 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/bluetooth/passive_update_coordinator.py b/homeassistant/components/bluetooth/passive_update_coordinator.py index 6f1749aeef2978..fcf6fcdf2551f3 100644 --- a/homeassistant/components/bluetooth/passive_update_coordinator.py +++ b/homeassistant/components/bluetooth/passive_update_coordinator.py @@ -85,6 +85,7 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" + self._available = True self.async_update_listeners() diff --git a/homeassistant/components/bluetooth/passive_update_processor.py b/homeassistant/components/bluetooth/passive_update_processor.py index 6d0621fa4f6074..7294d55f912d22 100644 --- a/homeassistant/components/bluetooth/passive_update_processor.py +++ b/homeassistant/components/bluetooth/passive_update_processor.py @@ -341,6 +341,8 @@ def _async_handle_bluetooth_event( change: BluetoothChange, ) -> None: """Handle a Bluetooth event.""" + was_available = self._available + self._available = True if self.hass.is_stopping: return @@ -358,7 +360,7 @@ def _async_handle_bluetooth_event( self.logger.info("Coordinator %s recovered", self.name) for processor in self._processors: - processor.async_handle_update(update) + processor.async_handle_update(update, was_available) _PassiveBluetoothDataProcessorT = TypeVar( @@ -515,20 +517,39 @@ def remove_listener() -> None: @callback def async_update_listeners( - self, data: PassiveBluetoothDataUpdate[_T] | None + self, + data: PassiveBluetoothDataUpdate[_T] | None, + was_available: bool | None = None, ) -> None: """Update all registered listeners.""" + if was_available is None: + was_available = self.coordinator.available + # Dispatch to listeners without a filter key for update_callback in self._listeners: update_callback(data) + if not was_available or data is None: + # When data is None, or was_available is False, + # dispatch to all listeners as it means the device + # is flipping between available and unavailable + for listeners in self._entity_key_listeners.values(): + for update_callback in listeners: + update_callback(data) + return + # Dispatch to listeners with a filter key - for listeners in self._entity_key_listeners.values(): - for update_callback in listeners: - update_callback(data) + # if the key is in the data + entity_key_listeners = self._entity_key_listeners + for entity_key in data.entity_data: + if maybe_listener := entity_key_listeners.get(entity_key): + for update_callback in maybe_listener: + update_callback(data) @callback - def async_handle_update(self, update: _T) -> None: + def async_handle_update( + self, update: _T, was_available: bool | None = None + ) -> None: """Handle a Bluetooth event.""" try: new_data = self.update_method(update) @@ -553,7 +574,7 @@ def async_handle_update(self, update: _T) -> None: ) self.data.update(new_data) - self.async_update_listeners(new_data) + self.async_update_listeners(new_data, was_available) class PassiveBluetoothProcessorEntity(Entity, Generic[_PassiveBluetoothDataProcessorT]): diff --git a/homeassistant/components/bluetooth/update_coordinator.py b/homeassistant/components/bluetooth/update_coordinator.py index 9c38bf2f5207cd..12bff3be645bdf 100644 --- a/homeassistant/components/bluetooth/update_coordinator.py +++ b/homeassistant/components/bluetooth/update_coordinator.py @@ -39,6 +39,8 @@ def __init__( self.mode = mode self._last_unavailable_time = 0.0 self._last_name = address + # Subclasses are responsible for setting _available to True + # when the abstractmethod _async_handle_bluetooth_event is called. self._available = async_address_present(hass, address, connectable) @callback @@ -88,23 +90,13 @@ def available(self) -> bool: """Return if the device is available.""" return self._available - @callback - def _async_handle_bluetooth_event_internal( - self, - service_info: BluetoothServiceInfoBleak, - change: BluetoothChange, - ) -> None: - """Handle a bluetooth event.""" - self._available = True - self._async_handle_bluetooth_event(service_info, change) - @callback def _async_start(self) -> None: """Start the callbacks.""" self._on_stop.append( async_register_callback( self.hass, - self._async_handle_bluetooth_event_internal, + self._async_handle_bluetooth_event, BluetoothCallbackMatcher( address=self.address, connectable=self.connectable ), diff --git a/tests/components/bluetooth/test_active_update_processor.py b/tests/components/bluetooth/test_active_update_processor.py index 83ad809016a253..fba86223a2d3d7 100644 --- a/tests/components/bluetooth/test_active_update_processor.py +++ b/tests/components/bluetooth/test_active_update_processor.py @@ -91,7 +91,7 @@ async def _poll(*args, **kwargs): # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) cancel() @@ -148,7 +148,7 @@ async def _poll(*args, **kwargs): inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, True) flag = True @@ -208,7 +208,7 @@ async def _poll(*args, **kwargs): # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) assert ( "aa:bb:cc:dd:ee:ff: Bluetooth error whilst polling: Connection was aborted" @@ -272,7 +272,7 @@ async def _poll(*args, **kwargs): # First poll fails inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) await hass.async_block_till_done() - assert async_handle_update.mock_calls[-1] == call({"testdata": None}) + assert async_handle_update.mock_calls[-1] == call({"testdata": None}, False) # Second poll works flag = False @@ -433,7 +433,7 @@ async def _poll(*args, **kwargs): # The first time, it was passed the data from parsing the advertisement # The second time, it was passed the data from polling assert len(async_handle_update.mock_calls) == 2 - assert async_handle_update.mock_calls[0] == call({"testdata": 0}) + assert async_handle_update.mock_calls[0] == call({"testdata": 0}, False) assert async_handle_update.mock_calls[1] == call({"testdata": 1}) hass.state = CoreState.stopping diff --git a/tests/components/bluetooth/test_passive_update_processor.py b/tests/components/bluetooth/test_passive_update_processor.py index c96fbfbfc99e24..5baff65f29ab04 100644 --- a/tests/components/bluetooth/test_passive_update_processor.py +++ b/tests/components/bluetooth/test_passive_update_processor.py @@ -858,22 +858,49 @@ def _async_generate_mock_data( mock_add_entities, ) + entity_key_events = [] + + 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, + PassiveBluetoothEntityKey(key="humidity", device_id="primary"), + ) + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # First call with just the remote sensor entities results in them being added assert len(mock_add_entities.mock_calls) == 1 + # should have triggered the entity key listener since the + # the device is becoming available + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # Second call with just the remote sensor entities does not add them again assert len(mock_add_entities.mock_calls) == 1 + # should not have triggered the entity key listener since there + # there is no update with the entity key + assert len(entity_key_events) == 1 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO) # Third call with primary and remote sensor entities adds the primary sensor entities 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) == 2 + inject_bluetooth_service_info(hass, GENERIC_BLUETOOTH_SERVICE_INFO_2) # 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 + entities = [ *mock_add_entities.mock_calls[0][1][0], *mock_add_entities.mock_calls[1][1][0], @@ -892,6 +919,7 @@ def _async_generate_mock_data( assert entity_one.entity_key == PassiveBluetoothEntityKey( key="temperature", device_id="remote" ) + cancel_async_add_entity_key_listener() cancel_coordinator() From ff2e0c570bca22aa618a1b2ec15c8ae83d6daeac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:53:59 -0500 Subject: [PATCH 106/640] Improve performance of google assistant supported checks (#99454) * Improve performance of google assistant supported checks * tweak * tweak * split function * tweak --- .../components/google_assistant/helpers.py | 121 ++++++++++++------ .../google_assistant/report_state.py | 23 +++- .../google_assistant/test_helpers.py | 61 ++++++--- .../google_assistant/test_report_state.py | 4 +- 4 files changed, 144 insertions(+), 65 deletions(-) diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index 49d130d665692c..c1b505b2bd49c2 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -5,6 +5,7 @@ from asyncio import gather from collections.abc import Callable, Mapping from datetime import datetime, timedelta +from functools import lru_cache from http import HTTPStatus import logging import pprint @@ -490,9 +491,34 @@ def get_google_type(domain, device_class): return typ if typ is not None else DOMAIN_TO_GOOGLE_TYPES[domain] +@lru_cache(maxsize=4096) +def supported_traits_for_state(state: State) -> list[type[trait._Trait]]: + """Return all supported traits for state.""" + domain = state.domain + attributes = state.attributes + features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) + + if not isinstance(features, int): + _LOGGER.warning( + "Entity %s contains invalid supported_features value %s", + state.entity_id, + features, + ) + return [] + + device_class = state.attributes.get(ATTR_DEVICE_CLASS) + return [ + Trait + for Trait in trait.TRAITS + if Trait.supported(domain, features, device_class, attributes) + ] + + class GoogleEntity: """Adaptation of Entity expressed in Google's terms.""" + __slots__ = ("hass", "config", "state", "_traits") + def __init__( self, hass: HomeAssistant, config: AbstractConfig, state: State ) -> None: @@ -502,6 +528,10 @@ def __init__( self.state = state self._traits: list[trait._Trait] | None = None + def __repr__(self) -> str: + """Return the representation.""" + return f"" + @property def entity_id(self): """Return entity ID.""" @@ -512,26 +542,10 @@ def traits(self) -> list[trait._Trait]: """Return traits for entity.""" if self._traits is not None: return self._traits - state = self.state - domain = state.domain - attributes = state.attributes - features = attributes.get(ATTR_SUPPORTED_FEATURES, 0) - - if not isinstance(features, int): - _LOGGER.warning( - "Entity %s contains invalid supported_features value %s", - self.entity_id, - features, - ) - return [] - - device_class = state.attributes.get(ATTR_DEVICE_CLASS) - self._traits = [ Trait(self.hass, state, self.config) - for Trait in trait.TRAITS - if Trait.supported(domain, features, device_class, attributes) + for Trait in supported_traits_for_state(state) ] return self._traits @@ -554,18 +568,8 @@ def should_expose_local(self) -> bool: @callback def is_supported(self) -> bool: - """Return if the entity is supported by Google.""" - features: int | None = self.state.attributes.get(ATTR_SUPPORTED_FEATURES) - - result = self.config.is_supported_cache.get(self.entity_id) - - if result is None or result[0] != features: - result = self.config.is_supported_cache[self.entity_id] = ( - features, - bool(self.traits()), - ) - - return result[1] + """Return if entity is supported.""" + return bool(self.traits()) @callback def might_2fa(self) -> bool: @@ -725,19 +729,64 @@ def deep_update(target, source): return target +@callback +def async_get_google_entity_if_supported_cached( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported checking the cache first. + + This function will check the cache, and call async_get_google_entity_if_supported + if the entity is not in the cache, which will update the cache. + """ + entity_id = state.entity_id + is_supported_cache = config.is_supported_cache + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + return GoogleEntity(hass, config, state) if supported else None + # Cache miss, check if entity is supported + return async_get_google_entity_if_supported(hass, config, state) + + +@callback +def async_get_google_entity_if_supported( + hass: HomeAssistant, config: AbstractConfig, state: State +) -> GoogleEntity | None: + """Return a GoogleEntity if entity is supported. + + This function will update the cache, but it does not check the cache first. + """ + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + entity = GoogleEntity(hass, config, state) + is_supported = bool(entity.traits()) + config.is_supported_cache[state.entity_id] = (features, is_supported) + return entity if is_supported else None + + @callback def async_get_entities( hass: HomeAssistant, config: AbstractConfig ) -> list[GoogleEntity]: """Return all entities that are supported by Google.""" - entities = [] + entities: list[GoogleEntity] = [] + is_supported_cache = config.is_supported_cache for state in hass.states.async_all(): - if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + entity_id = state.entity_id + if entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - - entity = GoogleEntity(hass, config, state) - - if entity.is_supported(): + # Check check inlined for performance to avoid + # function calls for every entity since we enumerate + # the entire state machine here + features: int | None = state.attributes.get(ATTR_SUPPORTED_FEATURES) + if result := is_supported_cache.get(entity_id): + cached_features, supported = result + if cached_features == features: + if supported: + entities.append(GoogleEntity(hass, config, state)) + continue + # Cached features don't match, fall through to check + # if the entity is supported and update the cache. + if entity := async_get_google_entity_if_supported(hass, config, state): entities.append(entity) - return entities diff --git a/homeassistant/components/google_assistant/report_state.py b/homeassistant/components/google_assistant/report_state.py index 5248ce7c4da0ae..52228bb8715d4f 100644 --- a/homeassistant/components/google_assistant/report_state.py +++ b/homeassistant/components/google_assistant/report_state.py @@ -6,13 +6,17 @@ from typing import Any from homeassistant.const import MATCH_ALL -from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, State, callback from homeassistant.helpers.event import async_call_later, async_track_state_change from homeassistant.helpers.significant_change import create_checker from .const import DOMAIN from .error import SmartHomeError -from .helpers import AbstractConfig, GoogleEntity, async_get_entities +from .helpers import ( + AbstractConfig, + async_get_entities, + async_get_google_entity_if_supported_cached, +) # Time to wait until the homegraph updates # https://github.com/actions-on-google/smart-home-nodejs/issues/196#issuecomment-439156639 @@ -54,8 +58,10 @@ async def report_states(now=None): report_states_job = HassJob(report_states) - async def async_entity_state_listener(changed_entity, old_state, new_state): - nonlocal unsub_pending + async def async_entity_state_listener( + changed_entity: str, old_state: State | None, new_state: State | None + ) -> None: + nonlocal unsub_pending, checker if not hass.is_running: return @@ -66,9 +72,11 @@ async def async_entity_state_listener(changed_entity, old_state, new_state): if not google_config.should_expose(new_state): return - entity = GoogleEntity(hass, google_config, new_state) - - if not entity.is_supported(): + if not ( + entity := async_get_google_entity_if_supported_cached( + hass, google_config, new_state + ) + ): return try: @@ -77,6 +85,7 @@ async def async_entity_state_listener(changed_entity, old_state, new_state): _LOGGER.debug("Not reporting state for %s: %s", changed_entity, err.code) return + assert checker is not None if not checker.async_is_significant_change(new_state, extra_arg=entity_data): return diff --git a/tests/components/google_assistant/test_helpers.py b/tests/components/google_assistant/test_helpers.py index 17df677110b223..001e8ff0d075e5 100644 --- a/tests/components/google_assistant/test_helpers.py +++ b/tests/components/google_assistant/test_helpers.py @@ -447,32 +447,53 @@ async def test_config_local_sdk_warn_version( ) in caplog.text -def test_is_supported_cached() -> None: - """Test is_supported is cached.""" +def test_async_get_entities_cached(hass: HomeAssistant) -> None: + """Test async_get_entities is cached.""" config = MockConfig() - def entity(features: int): - return helpers.GoogleEntity( - None, - config, - State("test.entity_id", "on", {"supported_features": features}), - ) + hass.states.async_set("light.ceiling_lights", "off") + hass.states.async_set("light.bed_light", "off") + hass.states.async_set("not_supported.not_supported", "off") + + google_entities = helpers.async_get_entities(hass, config) + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } with patch( "homeassistant.components.google_assistant.helpers.GoogleEntity.traits", - return_value=[1], - ) as mock_traits: - assert entity(1).is_supported() is True - assert len(mock_traits.mock_calls) == 1 + return_value=RuntimeError("Should not be called"), + ): + google_entities = helpers.async_get_entities(hass, config) - # Supported feature changes, so we calculate again - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 2 + assert len(google_entities) == 2 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } + + hass.states.async_set("light.new", "on") + google_entities = helpers.async_get_entities(hass, config) - mock_traits.reset_mock() + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (None, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } - # Supported feature is same, so we do not calculate again - mock_traits.side_effect = ValueError + hass.states.async_set("light.new", "on", {"supported_features": 1}) + google_entities = helpers.async_get_entities(hass, config) - assert entity(2).is_supported() is True - assert len(mock_traits.mock_calls) == 0 + assert len(google_entities) == 3 + assert config.is_supported_cache == { + "light.bed_light": (None, True), + "light.new": (1, True), + "light.ceiling_lights": (None, True), + "not_supported.not_supported": (None, False), + } diff --git a/tests/components/google_assistant/test_report_state.py b/tests/components/google_assistant/test_report_state.py index 3fe2a749fcad16..d6f4043d2f7d22 100644 --- a/tests/components/google_assistant/test_report_state.py +++ b/tests/components/google_assistant/test_report_state.py @@ -69,7 +69,7 @@ async def test_report_state( # Test that if serialize returns same value, we don't send with patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", return_value={"same": "info"}, ), patch.object(BASIC_CONFIG, "async_report_state_all", AsyncMock()) as mock_report: # New state, so reported @@ -104,7 +104,7 @@ async def test_report_state( with patch.object( BASIC_CONFIG, "async_report_state_all", AsyncMock() ) as mock_report, patch( - "homeassistant.components.google_assistant.report_state.GoogleEntity.query_serialize", + "homeassistant.components.google_assistant.helpers.GoogleEntity.query_serialize", side_effect=error.SmartHomeError("mock-error", "mock-msg"), ): hass.states.async_set("light.kitchen", "off") From fed1cab847f055302914e46ee2d8d129e1e0a5fa Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 4 Sep 2023 19:56:34 -0500 Subject: [PATCH 107/640] Fix mobile app dispatcher performance (#99647) Fix mobile app thundering heard The mobile_app would setup a dispatcher to listener for updates on every entity and reject the ones that were not for the unique id that it was intrested in. Instead we now register for a signal per unique id since we were previously generating O(entities*sensors*devices) callbacks which was causing the event loop to stall when there were a large number of mobile app users. --- homeassistant/components/mobile_app/entity.py | 13 +++++++------ homeassistant/components/mobile_app/webhook.py | 5 ++--- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 3a2f038a0af007..120014d1d52a37 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -1,6 +1,8 @@ """A entity class for mobile_app.""" from __future__ import annotations +from typing import Any + from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ICON, CONF_NAME, CONF_UNIQUE_ID, STATE_UNAVAILABLE from homeassistant.core import callback @@ -36,7 +38,9 @@ async def async_added_to_hass(self): """Register callbacks.""" self.async_on_remove( async_dispatcher_connect( - self.hass, SIGNAL_SENSOR_UPDATE, self._handle_update + self.hass, + f"{SIGNAL_SENSOR_UPDATE}-{self._attr_unique_id}", + self._handle_update, ) ) @@ -96,10 +100,7 @@ def available(self) -> bool: return self._config.get(ATTR_SENSOR_STATE) != STATE_UNAVAILABLE @callback - def _handle_update(self, incoming_id, data): + def _handle_update(self, data: dict[str, Any]) -> None: """Handle async event updates.""" - if incoming_id != self._attr_unique_id: - return - - self._config = {**self._config, **data} + self._config.update(data) self.async_write_ha_state() diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 62417b0873a9a2..1a56b13ddc59d0 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -607,7 +607,7 @@ async def webhook_register_sensor( if changes: entity_registry.async_update_entity(existing_sensor, **changes) - async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, unique_store_key, data) + async_dispatcher_send(hass, f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", data) else: data[CONF_UNIQUE_ID] = unique_store_key data[ @@ -693,8 +693,7 @@ async def webhook_update_sensor_states( sensor[CONF_WEBHOOK_ID] = config_entry.data[CONF_WEBHOOK_ID] async_dispatcher_send( hass, - SIGNAL_SENSOR_UPDATE, - unique_store_key, + f"{SIGNAL_SENSOR_UPDATE}-{unique_store_key}", sensor, ) From 49bd7e62519059ef7236d0ae3a1761ceca2070ef Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 02:59:44 +0200 Subject: [PATCH 108/640] Use shorthand attributes for Picnic (#99633) --- homeassistant/components/picnic/sensor.py | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/picnic/sensor.py b/homeassistant/components/picnic/sensor.py index d4582afa3b2bbc..6e35c27bbfb27d 100644 --- a/homeassistant/components/picnic/sensor.py +++ b/homeassistant/components/picnic/sensor.py @@ -256,9 +256,15 @@ def __init__( self.entity_description = description self.entity_id = f"sensor.picnic_{description.key}" - self._service_unique_id = config_entry.unique_id self._attr_unique_id = f"{config_entry.unique_id}.{description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, cast(str, config_entry.unique_id))}, + manufacturer="Picnic", + model=config_entry.unique_id, + name=f"Picnic: {coordinator.data[ADDRESS]}", + ) @property def native_value(self) -> StateType | datetime: @@ -269,14 +275,3 @@ def native_value(self) -> StateType | datetime: else {} ) return self.entity_description.value_fn(data_set) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, cast(str, self._service_unique_id))}, - manufacturer="Picnic", - model=self._service_unique_id, - name=f"Picnic: {self.coordinator.data[ADDRESS]}", - ) From 2c3a4b349736564b13125bbf5db8523aed08f9cd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 5 Sep 2023 08:57:09 +0200 Subject: [PATCH 109/640] Bump actions/checkout from 3.6.0 to 4.0.0 (#99651) --- .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 3296f33f84c595..6ac535647b8343 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@v3.6.0 + uses: actions/checkout@v4.0.0 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@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 @@ -98,7 +98,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download nightly wheels of frontend if: needs.init.outputs.channel == 'dev' @@ -254,7 +254,7 @@ jobs: - green steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set build additional args run: | @@ -293,7 +293,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Initialize git uses: home-assistant/actions/helpers/git-init@master @@ -331,7 +331,7 @@ jobs: id-token: write steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Install Cosign uses: sigstore/cosign-installer@v3.1.1 diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index bf6ba38ea91e48..9651b1394d8276 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -87,7 +87,7 @@ jobs: runs-on: ubuntu-22.04 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Generate partial Python venv restore key id: generate_python_cache_key run: >- @@ -220,7 +220,7 @@ jobs: - info steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -265,7 +265,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -311,7 +311,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -360,7 +360,7 @@ jobs: - pre-commit steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 id: python @@ -454,7 +454,7 @@ jobs: python-version: ${{ fromJSON(needs.info.outputs.python_versions) }} steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -522,7 +522,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -554,7 +554,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -587,7 +587,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -631,7 +631,7 @@ jobs: - base steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} id: python uses: actions/setup-python@v4.7.0 @@ -713,7 +713,7 @@ jobs: bluez \ ffmpeg - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -865,7 +865,7 @@ jobs: ffmpeg \ libmariadb-dev-compat - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -989,7 +989,7 @@ jobs: ffmpeg \ postgresql-server-dev-14 - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ matrix.python-version }} id: python uses: actions/setup-python@v4.7.0 @@ -1084,7 +1084,7 @@ jobs: timeout-minutes: 10 steps: - name: Check out code from GitHub - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - 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 5affa459f52d54..a98c4d99734fce 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@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Set up Python ${{ env.DEFAULT_PYTHON }} uses: actions/setup-python@v4.7.0 diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 01823199c17cc0..6d947f51acaf3c 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -26,7 +26,7 @@ jobs: architectures: ${{ steps.info.outputs.architectures }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Get information id: info @@ -84,7 +84,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 @@ -122,7 +122,7 @@ jobs: arch: ${{ fromJson(needs.init.outputs.architectures) }} steps: - name: Checkout the repository - uses: actions/checkout@v3.6.0 + uses: actions/checkout@v4.0.0 - name: Download env_file uses: actions/download-artifact@v3 From f13e7706ed2f8baad6d5d2c3cf941a6f7984ccba Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 11:54:53 +0200 Subject: [PATCH 110/640] Use shorthand attributes in Nuheat (#99618) --- homeassistant/components/nuheat/climate.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/nuheat/climate.py b/homeassistant/components/nuheat/climate.py index 18b34ea0beadf0..4daaee10ea68d7 100644 --- a/homeassistant/components/nuheat/climate.py +++ b/homeassistant/components/nuheat/climate.py @@ -77,6 +77,7 @@ class NuHeatThermostat(CoordinatorEntity, ClimateEntity): ) _attr_has_entity_name = True _attr_name = None + _attr_preset_modes = PRESET_MODES def __init__(self, coordinator, thermostat, temperature_unit): """Initialize the thermostat.""" @@ -85,6 +86,7 @@ def __init__(self, coordinator, thermostat, temperature_unit): self._temperature_unit = temperature_unit self._schedule_mode = None self._target_temperature = None + self._attr_unique_id = thermostat.serial_number @property def temperature_unit(self) -> str: @@ -102,11 +104,6 @@ def current_temperature(self): return self._thermostat.fahrenheit - @property - def unique_id(self): - """Return the unique id.""" - return self._thermostat.serial_number - @property def available(self) -> bool: """Return the unique id.""" @@ -160,11 +157,6 @@ def preset_mode(self): """Return current preset mode.""" return SCHEDULE_MODE_TO_PRESET_MODE_MAP.get(self._schedule_mode, PRESET_RUN) - @property - def preset_modes(self): - """Return available preset modes.""" - return PRESET_MODES - def set_preset_mode(self, preset_mode: str) -> None: """Update the hold mode of the thermostat.""" self._set_schedule_mode( From c77a0a8caae8aaafb3c1bbbf2b7bc784fb488977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 5 Sep 2023 19:42:19 +0900 Subject: [PATCH 111/640] Update aioairzone to v0.6.8 (#99644) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update aioairzone to v0.6.8 Signed-off-by: Álvaro Fernández Rojas * Trigger CI --------- Signed-off-by: Álvaro Fernández Rojas --- 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 bb1e448c8ebbf3..c0b24b2cc3e8e4 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.7"] + "requirements": ["aioairzone==0.6.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4996ca70ea0092..6d24193a01502b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -189,7 +189,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 522f891f7ba67c..c5d0f31a8e8272 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -170,7 +170,7 @@ aioairq==0.2.4 aioairzone-cloud==0.2.1 # homeassistant.components.airzone -aioairzone==0.6.7 +aioairzone==0.6.8 # homeassistant.components.ambient_station aioambient==2023.04.0 From 582eeea08215a8e553e90c281fcfb4774788daca Mon Sep 17 00:00:00 2001 From: itpeters <59966384+itpeters@users.noreply.github.com> Date: Tue, 5 Sep 2023 05:10:14 -0600 Subject: [PATCH 112/640] Fix long press event for matter generic switch (#99645) --- homeassistant/components/matter/event.py | 2 +- tests/components/matter/test_event.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index 3a1faa6dcbef84..84049301296b10 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -65,7 +65,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: if feature_map & SwitchFeature.kMomentarySwitchRelease: event_types.append("short_release") if feature_map & SwitchFeature.kMomentarySwitchLongPress: - event_types.append("long_press_ongoing") + event_types.append("long_press") event_types.append("long_release") if feature_map & SwitchFeature.kMomentarySwitchMultiPress: event_types.append("multi_press_ongoing") diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 0d5891a7778cfa..0aa9385a74c6cb 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -48,7 +48,7 @@ async def test_generic_switch_node( assert state.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", "multi_press_ongoing", "multi_press_complete", @@ -111,7 +111,7 @@ async def test_generic_switch_multi_node( assert state_button_1.attributes[ATTR_EVENT_TYPES] == [ "initial_press", "short_release", - "long_press_ongoing", + "long_press", "long_release", ] # check button 2 From d5ad01ffbe473dfbd7548c6722403c15cf76d745 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 14:57:26 +0200 Subject: [PATCH 113/640] Use shorthand attributes in Openhome (#99629) --- .../components/openhome/media_player.py | 21 ++++++++----------- homeassistant/components/openhome/update.py | 14 +++++-------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/openhome/media_player.py b/homeassistant/components/openhome/media_player.py index efc6ab37f21aa9..51d7774a2fb865 100644 --- a/homeassistant/components/openhome/media_player.py +++ b/homeassistant/components/openhome/media_player.py @@ -102,26 +102,23 @@ async def wrapper( class OpenhomeDevice(MediaPlayerEntity): """Representation of an Openhome device.""" + _attr_supported_features = SUPPORT_OPENHOME + _attr_state = MediaPlayerState.PLAYING + _attr_available = True + def __init__(self, hass, device): """Initialise the Openhome device.""" self.hass = hass self._device = device self._attr_unique_id = device.uuid() - self._attr_supported_features = SUPPORT_OPENHOME self._source_index = {} - self._attr_state = MediaPlayerState.PLAYING - self._attr_available = True - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: diff --git a/homeassistant/components/openhome/update.py b/homeassistant/components/openhome/update.py index 9013e50030fbcc..691776e4dfd6cc 100644 --- a/homeassistant/components/openhome/update.py +++ b/homeassistant/components/openhome/update.py @@ -54,17 +54,13 @@ def __init__(self, device): """Initialize a Linn DS update entity.""" self._device = device self._attr_unique_id = f"{device.uuid()}-update" - - @property - def device_info(self): - """Return a device description for device registry.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( identifiers={ - (DOMAIN, self._device.uuid()), + (DOMAIN, device.uuid()), }, - manufacturer=self._device.manufacturer(), - model=self._device.model_name(), - name=self._device.friendly_name(), + manufacturer=device.manufacturer(), + model=device.model_name(), + name=device.friendly_name(), ) async def async_update(self) -> None: From c49f086790c1b63988679468a91b84766192340a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:22:52 +0200 Subject: [PATCH 114/640] Use shorthand attributes in Kodi (#99578) --- homeassistant/components/kodi/media_player.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/kodi/media_player.py b/homeassistant/components/kodi/media_player.py index 9c69abc08c8159..32ecbbed6260a2 100644 --- a/homeassistant/components/kodi/media_player.py +++ b/homeassistant/components/kodi/media_player.py @@ -282,7 +282,7 @@ def __init__(self, connection, kodi, name, uid): """Initialize the Kodi entity.""" self._connection = connection self._kodi = kodi - self._unique_id = uid + self._attr_unique_id = uid self._device_id = None self._players = None self._properties = {} @@ -369,11 +369,6 @@ async def _clear_connection(self, close=True): if close: await self._connection.close() - @property - def unique_id(self): - """Return the unique id of the device.""" - return self._unique_id - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" From 447a9f4aad04feab7ba13baa0681d79c7b09f8a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:25:00 +0200 Subject: [PATCH 115/640] Use shorthand attributes in Konnected (#99580) --- .../components/konnected/binary_sensor.py | 40 ++++--------------- homeassistant/components/konnected/sensor.py | 15 +++---- homeassistant/components/konnected/switch.py | 29 +++----------- 3 files changed, 17 insertions(+), 67 deletions(-) diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index 2f21f8c15bda89..d7c41337342c1b 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -42,38 +42,12 @@ class KonnectedBinarySensor(BinarySensorEntity): def __init__(self, device_id, zone_num, data): """Initialize the Konnected binary sensor.""" self._data = data - self._device_id = device_id - self._zone_num = zone_num - self._state = self._data.get(ATTR_STATE) - self._device_class = self._data.get(CONF_TYPE) - self._unique_id = f"{device_id}-{zone_num}" - self._name = self._data.get(CONF_NAME) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(KONNECTED_DOMAIN, self._device_id)}, + self._attr_is_on = data.get(ATTR_STATE) + self._attr_device_class = data.get(CONF_TYPE) + self._attr_unique_id = f"{device_id}-{zone_num}" + self._attr_name = data.get(CONF_NAME) + self._attr_device_info = DeviceInfo( + identifiers={(KONNECTED_DOMAIN, device_id)}, ) async def async_added_to_hass(self) -> None: @@ -88,5 +62,5 @@ async def async_added_to_hass(self) -> None: @callback def async_set_state(self, state): """Update the sensor's state.""" - self._state = state + self._attr_is_on = state self.async_write_ha_state() diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py index b341afa765f0ef..3f203d5f3e8240 100644 --- a/homeassistant/components/konnected/sensor.py +++ b/homeassistant/components/konnected/sensor.py @@ -111,9 +111,9 @@ def __init__( self._attr_unique_id = addr or f"{device_id}-{self._zone_num}-{description.key}" # set initial state if known at initialization - self._state = initial_state - if self._state: - self._state = round(float(self._state), 1) + self._attr_native_value = initial_state + if initial_state: + self._attr_native_value = round(float(initial_state), 1) # set entity name if given if name := self._data.get(CONF_NAME): @@ -122,11 +122,6 @@ def __init__( self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - async def async_added_to_hass(self) -> None: """Store entity_id and register state change callback.""" entity_id_key = self._addr or self.entity_description.key @@ -139,7 +134,7 @@ async def async_added_to_hass(self) -> None: def async_set_state(self, state): """Update the sensor's state.""" if self.entity_description.key == "humidity": - self._state = int(float(state)) + self._attr_native_value = int(float(state)) else: - self._state = round(float(state), 1) + self._attr_native_value = round(float(state), 1) self.async_write_ha_state() diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index ba0dc62b60613f..18132a913add16 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -56,27 +56,13 @@ def __init__(self, device_id, zone_num, data): self._momentary = self._data.get(CONF_MOMENTARY) self._pause = self._data.get(CONF_PAUSE) self._repeat = self._data.get(CONF_REPEAT) - self._state = self._boolean_state(self._data.get(ATTR_STATE)) - self._name = self._data.get(CONF_NAME) - self._unique_id = ( + self._attr_is_on = self._boolean_state(self._data.get(ATTR_STATE)) + self._attr_name = self._data.get(CONF_NAME) + self._attr_unique_id = ( f"{device_id}-{self._zone_num}-{self._momentary}-" f"{self._pause}-{self._repeat}" ) - - @property - def unique_id(self) -> str: - """Return the unique id.""" - return self._unique_id - - @property - def name(self): - """Return the name of the switch.""" - return self._name - - @property - def is_on(self): - """Return the status of the sensor.""" - return self._state + self._attr_device_info = DeviceInfo(identifiers={(KONNECTED_DOMAIN, device_id)}) @property def panel(self): @@ -84,11 +70,6 @@ def panel(self): device_data = self.hass.data[KONNECTED_DOMAIN][CONF_DEVICES][self._device_id] return device_data.get("panel") - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo(identifiers={(KONNECTED_DOMAIN, self._device_id)}) - @property def available(self) -> bool: """Return whether the panel is available.""" @@ -129,7 +110,7 @@ def _boolean_state(self, int_state): return self._activation == STATE_HIGH def _set_state(self, state): - self._state = state + self._attr_is_on = state self.async_write_ha_state() _LOGGER.debug( "Setting status of %s actuator zone %s to %s", From 1f648feaeff90720b22fd4d7af40db7bc265f3a4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:26:23 +0200 Subject: [PATCH 116/640] Use shorthand attributes in Kostal Plenticore (#99581) --- .../components/kostal_plenticore/sensor.py | 21 +++---------------- .../components/kostal_plenticore/switch.py | 8 +------ 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/kostal_plenticore/sensor.py b/homeassistant/components/kostal_plenticore/sensor.py index 78ab609aa16f59..f7bad638df4ff2 100644 --- a/homeassistant/components/kostal_plenticore/sensor.py +++ b/homeassistant/components/kostal_plenticore/sensor.py @@ -745,16 +745,16 @@ def __init__( super().__init__(coordinator) self.entity_description = description self.entry_id = entry_id - self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key - self._sensor_name = description.name self._formatter: Callable[[str], Any] = PlenticoreDataFormatter.get_method( description.formatter ) - self._device_info = device_info + self._attr_device_info = device_info + self._attr_unique_id = f"{entry_id}_{self.module_id}_{self.data_id}" + self._attr_name = f"{platform_name} {description.name}" @property def available(self) -> bool: @@ -778,21 +778,6 @@ async def async_will_remove_from_hass(self) -> None: self.coordinator.stop_fetch_data(self.module_id, self.data_id) await super().async_will_remove_from_hass() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - - @property - def unique_id(self) -> str: - """Return the unique id of this Sensor Entity.""" - return f"{self.entry_id}_{self.module_id}_{self.data_id}" - - @property - def name(self) -> str: - """Return the name of this Sensor Entity.""" - return f"{self.platform_name} {self._sensor_name}" - @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/kostal_plenticore/switch.py b/homeassistant/components/kostal_plenticore/switch.py index 574368b432f78a..554f8db2b68eb8 100644 --- a/homeassistant/components/kostal_plenticore/switch.py +++ b/homeassistant/components/kostal_plenticore/switch.py @@ -116,7 +116,6 @@ def __init__( """Create a new Switch Entity for Plenticore process data.""" super().__init__(coordinator) self.entity_description = description - self.entry_id = entry_id self.platform_name = platform_name self.module_id = description.module_id self.data_id = description.key @@ -129,7 +128,7 @@ def __init__( self.off_label = description.off_label self._attr_unique_id = f"{entry_id}_{description.module_id}_{description.key}" - self._device_info = device_info + self._attr_device_info = device_info @property def available(self) -> bool: @@ -171,11 +170,6 @@ async def async_turn_off(self, **kwargs: Any) -> None: ) await self.coordinator.async_request_refresh() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return self._device_info - @property def is_on(self) -> bool: """Return true if device is on.""" From 0ae12ad08f4ffc6e1560665a44846784c79645d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:27:38 +0200 Subject: [PATCH 117/640] Use shorthand attributes in Logi circle (#99592) --- .../components/logi_circle/camera.py | 27 +++++++------------ .../components/logi_circle/sensor.py | 14 ++++------ 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/logi_circle/camera.py b/homeassistant/components/logi_circle/camera.py index 5c27d2a08ae4fb..d1ea01e864cc74 100644 --- a/homeassistant/components/logi_circle/camera.py +++ b/homeassistant/components/logi_circle/camera.py @@ -71,10 +71,17 @@ def __init__(self, camera, ffmpeg): """Initialize Logi Circle camera.""" super().__init__() self._camera = camera - self._id = self._camera.mac_address - self._has_battery = self._camera.supports_feature("battery_level") + self._has_battery = camera.supports_feature("battery_level") self._ffmpeg = ffmpeg self._listeners = [] + self._attr_unique_id = camera.mac_address + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, + manufacturer=DEVICE_BRAND, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, + ) async def async_added_to_hass(self) -> None: """Connect camera methods to signals.""" @@ -117,22 +124,6 @@ async def async_will_remove_from_hass(self) -> None: for detach in self._listeners: detach() - @property - def unique_id(self): - """Return a unique ID.""" - return self._id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, - manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, - ) - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/logi_circle/sensor.py b/homeassistant/components/logi_circle/sensor.py index 32082b794b73d1..d06569a19ca90a 100644 --- a/homeassistant/components/logi_circle/sensor.py +++ b/homeassistant/components/logi_circle/sensor.py @@ -106,16 +106,12 @@ def __init__(self, camera, time_zone, description: SensorEntityDescription) -> N self._attr_unique_id = f"{camera.mac_address}-{description.key}" self._activity: dict[Any, Any] = {} self._tz = time_zone - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - identifiers={(LOGI_CIRCLE_DOMAIN, self._camera.id)}, + self._attr_device_info = DeviceInfo( + identifiers={(LOGI_CIRCLE_DOMAIN, camera.id)}, manufacturer=DEVICE_BRAND, - model=self._camera.model_name, - name=self._camera.name, - sw_version=self._camera.firmware, + model=camera.model_name, + name=camera.name, + sw_version=camera.firmware, ) @property From 58af0ab0cda751046d647b32e9166d5254405f1d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:37:00 +0200 Subject: [PATCH 118/640] Use shorthand attributes in NZBGet (#99622) --- homeassistant/components/nzbget/switch.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/nzbget/switch.py b/homeassistant/components/nzbget/switch.py index e6a2b213873e3f..5d72cae37cf5be 100644 --- a/homeassistant/components/nzbget/switch.py +++ b/homeassistant/components/nzbget/switch.py @@ -47,7 +47,7 @@ def __init__( entry_name: str, ) -> None: """Initialize a new NZBGet switch.""" - self._unique_id = f"{entry_id}_download" + self._attr_unique_id = f"{entry_id}_download" super().__init__( coordinator=coordinator, @@ -55,11 +55,6 @@ def __init__( entry_name=entry_name, ) - @property - def unique_id(self) -> str: - """Return the unique ID of the switch.""" - return self._unique_id - @property def is_on(self): """Return the state of the switch.""" From 3c8204528939d6a1f8b12867b684d591bea216c2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:40:11 +0200 Subject: [PATCH 119/640] Use shorthand attributes in Omnilogic (#99626) --- homeassistant/components/omnilogic/sensor.py | 35 ++++++-------------- 1 file changed, 11 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/omnilogic/sensor.py b/homeassistant/components/omnilogic/sensor.py index 5cb7605b854259..be082584308953 100644 --- a/homeassistant/components/omnilogic/sensor.py +++ b/homeassistant/components/omnilogic/sensor.py @@ -66,7 +66,7 @@ def __init__( coordinator: OmniLogicUpdateCoordinator, kind: str, name: str, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, item_id: tuple, @@ -85,20 +85,10 @@ def __init__( unit_type = coordinator.data[backyard_id].get("Unit-of-Measurement") self._unit_type = unit_type - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit self._state_key = state_key - @property - def device_class(self): - """Return the device class of the entity.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the right unit of measure.""" - return self._unit - class OmniLogicTemperatureSensor(OmnilogicSensor): """Define an OmniLogic Temperature (Air/Water) Sensor.""" @@ -123,7 +113,7 @@ def native_value(self): self._attrs["hayward_temperature"] = hayward_state self._attrs["hayward_unit_of_measure"] = hayward_unit_of_measure - self._unit = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT return state @@ -143,10 +133,10 @@ def native_value(self): pump_speed = self.coordinator.data[self._item_id][self._state_key] if pump_type == "VARIABLE": - self._unit = PERCENTAGE + self._attr_native_unit_of_measurement = PERCENTAGE state = pump_speed elif pump_type == "DUAL": - self._unit = None + self._attr_native_unit_of_measurement = None if pump_speed == 0: state = "off" elif pump_speed == self.coordinator.data[self._item_id].get( @@ -171,13 +161,12 @@ def native_value(self): """Return the state for the salt level sensor.""" salt_return = self.coordinator.data[self._item_id][self._state_key] - unit_of_measurement = self._unit if self._unit_type == "Metric": salt_return = round(int(salt_return) / 1000, 2) - unit_of_measurement = f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" - - self._unit = unit_of_measurement + self._attr_native_unit_of_measurement = ( + f"{UnitOfMass.GRAMS}/{UnitOfVolume.LITERS}" + ) return salt_return @@ -188,9 +177,7 @@ class OmniLogicChlorinatorSensor(OmnilogicSensor): @property def native_value(self): """Return the state for the chlorinator sensor.""" - state = self.coordinator.data[self._item_id][self._state_key] - - return state + return self.coordinator.data[self._item_id][self._state_key] class OmniLogicPHSensor(OmnilogicSensor): @@ -224,7 +211,7 @@ def __init__( name: str, kind: str, item_id: tuple, - device_class: str, + device_class: SensorDeviceClass | None, icon: str, unit: str, ) -> None: From c6bdc380b662dcfd7bf3910b546c77d2b1ae9ac0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 15:40:43 +0200 Subject: [PATCH 120/640] Use shorthand attributes in Ondilo ico (#99627) --- homeassistant/components/ondilo_ico/sensor.py | 20 +++++++------------ 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/ondilo_ico/sensor.py b/homeassistant/components/ondilo_ico/sensor.py index 4345f3498fd653..90c79003b8adcd 100644 --- a/homeassistant/components/ondilo_ico/sensor.py +++ b/homeassistant/components/ondilo_ico/sensor.py @@ -153,7 +153,13 @@ def __init__( pooldata = self._pooldata() self._attr_unique_id = f"{pooldata['ICO']['serial_number']}-{description.key}" - self._device_name = pooldata["name"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, + manufacturer="Ondilo", + model="ICO", + name=pooldata["name"], + sw_version=pooldata["ICO"]["sw_version"], + ) def _pooldata(self): """Get pool data dict.""" @@ -177,15 +183,3 @@ def _devdata(self): def native_value(self): """Last value of the sensor.""" return self._devdata()["value"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for the sensor.""" - pooldata = self._pooldata() - return DeviceInfo( - identifiers={(DOMAIN, pooldata["ICO"]["serial_number"])}, - manufacturer="Ondilo", - model="ICO", - name=self._device_name, - sw_version=pooldata["ICO"]["sw_version"], - ) From 5afba6327c10ea65c8a5db60543846f21c019f18 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 5 Sep 2023 09:58:32 -0400 Subject: [PATCH 121/640] Bump zwave-js-server-python to 0.51.1 (#99652) * Bump zwave-js-server-python to 0.51.1 * Update test --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/zwave_js/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zwave_js/test_api.py | 5 ++--- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/zwave_js/manifest.json b/homeassistant/components/zwave_js/manifest.json index 73fa41a8cca1ec..080074451bda69 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.51.0"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.1"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 6d24193a01502b..e4f4ebd5885fb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2801,7 +2801,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c5d0f31a8e8272..7bcf9cf3f47123 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2065,7 +2065,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.0 +zwave-js-server-python==0.51.1 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/tests/components/zwave_js/test_api.py b/tests/components/zwave_js/test_api.py index e686def8883cb4..02ed507cabea8d 100644 --- a/tests/components/zwave_js/test_api.py +++ b/tests/components/zwave_js/test_api.py @@ -3679,7 +3679,6 @@ async def test_abort_firmware_update( ws_client = await hass_ws_client(hass) device = get_device(hass, multisensor_6) - client.async_send_command.return_value = {} await ws_client.send_json( { ID: 1, @@ -3690,8 +3689,8 @@ async def test_abort_firmware_update( msg = await ws_client.receive_json() assert msg["success"] - assert len(client.async_send_command.call_args_list) == 1 - args = client.async_send_command.call_args[0][0] + assert len(client.async_send_command_no_wait.call_args_list) == 1 + args = client.async_send_command_no_wait.call_args[0][0] assert args["command"] == "node.abort_firmware_update" assert args["nodeId"] == multisensor_6.node_id From 2c45d43e7b87d9e911df3e356dcbd1898cded692 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 16:33:46 +0200 Subject: [PATCH 122/640] Use shorthand attributes in Neato (#99605) Co-authored-by: Robert Resch --- homeassistant/components/neato/camera.py | 6 +--- homeassistant/components/neato/entity.py | 6 +--- homeassistant/components/neato/sensor.py | 38 ++++++------------------ homeassistant/components/neato/switch.py | 26 ++++------------ homeassistant/components/neato/vacuum.py | 26 +++++++--------- 5 files changed, 27 insertions(+), 75 deletions(-) diff --git a/homeassistant/components/neato/camera.py b/homeassistant/components/neato/camera.py index c1513bb1de6a27..9ce66a53622331 100644 --- a/homeassistant/components/neato/camera.py +++ b/homeassistant/components/neato/camera.py @@ -57,6 +57,7 @@ def __init__( self._mapdata = mapdata self._available = neato is not None self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._generated_at: str | None = None self._image_url: str | None = None self._image: bytes | None = None @@ -109,11 +110,6 @@ def update(self) -> None: self._generated_at = map_data.get("generated_at") self._available = True - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - @property def available(self) -> bool: """Return if the robot is available.""" diff --git a/homeassistant/components/neato/entity.py b/homeassistant/components/neato/entity.py index 43072f1969391f..46ad358c63800b 100644 --- a/homeassistant/components/neato/entity.py +++ b/homeassistant/components/neato/entity.py @@ -17,11 +17,7 @@ class NeatoEntity(Entity): def __init__(self, robot: Robot) -> None: """Initialize Neato entity.""" self.robot = robot - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( + self._attr_device_info: DeviceInfo = DeviceInfo( identifiers={(NEATO_DOMAIN, self.robot.serial)}, name=self.robot.name, ) diff --git a/homeassistant/components/neato/sensor.py b/homeassistant/components/neato/sensor.py index 452f1bc3a9c740..3b68ddcf3dfef3 100644 --- a/homeassistant/components/neato/sensor.py +++ b/homeassistant/components/neato/sensor.py @@ -44,11 +44,16 @@ async def async_setup_entry( class NeatoSensor(NeatoEntity, SensorEntity): """Neato sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_entity_category = EntityCategory.DIAGNOSTIC + _attr_native_unit_of_measurement = PERCENTAGE + _attr_available: bool = False + def __init__(self, neato: NeatoHub, robot: Robot) -> None: """Initialize Neato sensor.""" super().__init__(robot) - self._available: bool = False self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial self._state: dict[str, Any] | None = None def update(self) -> None: @@ -56,45 +61,20 @@ def update(self) -> None: try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: + if self._attr_available: _LOGGER.error( "Neato sensor connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) - @property - def unique_id(self) -> str: - """Return unique ID.""" - return self._robot_serial - - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class.""" - return SensorDeviceClass.BATTERY - - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.DIAGNOSTIC - - @property - def available(self) -> bool: - """Return availability.""" - return self._available - @property def native_value(self) -> str | None: """Return the state.""" if self._state is not None: return str(self._state["details"]["charge"]) return None - - @property - def native_unit_of_measurement(self) -> str: - """Return unit of measurement.""" - return PERCENTAGE diff --git a/homeassistant/components/neato/switch.py b/homeassistant/components/neato/switch.py index a80d05eef2367c..ae90a8230b2876 100644 --- a/homeassistant/components/neato/switch.py +++ b/homeassistant/components/neato/switch.py @@ -49,16 +49,17 @@ class NeatoConnectedSwitch(NeatoEntity, SwitchEntity): """Neato Connected Switches.""" _attr_translation_key = "schedule" + _attr_available = False + _attr_entity_category = EntityCategory.CONFIG def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None: """Initialize the Neato Connected switches.""" super().__init__(robot) self.type = switch_type - self._available = False self._state: dict[str, Any] | None = None self._schedule_state: str | None = None self._clean_state = None - self._robot_serial: str = self.robot.serial + self._attr_unique_id = self.robot.serial def update(self) -> None: """Update the states of Neato switches.""" @@ -66,15 +67,15 @@ def update(self) -> None: try: self._state = self.robot.state except NeatoRobotException as ex: - if self._available: # Print only once when available + if self._attr_available: # Print only once when available _LOGGER.error( "Neato switch connection error for '%s': %s", self.entity_id, ex ) self._state = None - self._available = False + self._attr_available = False return - self._available = True + self._attr_available = True _LOGGER.debug("self._state=%s", self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) @@ -86,16 +87,6 @@ def update(self) -> None: "Schedule state for '%s': %s", self.entity_id, self._schedule_state ) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._robot_serial - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -103,11 +94,6 @@ def is_on(self) -> bool: self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON ) - @property - def entity_category(self) -> EntityCategory: - """Device entity category.""" - return EntityCategory.CONFIG - def turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" if self.type == SWITCH_TYPE_SCHEDULE: diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index ecc39e515c25e9..891b090d5d360e 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -124,7 +124,6 @@ def __init__( self._robot_serial: str = self.robot.serial self._attr_unique_id: str = self.robot.serial self._status_state: str | None = None - self._clean_state: str | None = None self._state: dict[str, Any] | None = None self._clean_time_start: str | None = None self._clean_time_stop: str | None = None @@ -169,23 +168,23 @@ def update(self) -> None: robot_alert = None if self._state["state"] == 1: if self._state["details"]["isCharging"]: - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Charging" elif ( self._state["details"]["isDocked"] and not self._state["details"]["isCharging"] ): - self._clean_state = STATE_DOCKED + self._attr_state = STATE_DOCKED self._status_state = "Docked" else: - self._clean_state = STATE_IDLE + self._attr_state = STATE_IDLE self._status_state = "Stopped" if robot_alert is not None: self._status_state = robot_alert elif self._state["state"] == 2: if robot_alert is None: - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING self._status_state = ( f"{MODE.get(self._state['cleaning']['mode'])} " f"{ACTION.get(self._state['action'])}" @@ -200,10 +199,10 @@ def update(self) -> None: else: self._status_state = robot_alert elif self._state["state"] == 3: - self._clean_state = STATE_PAUSED + self._attr_state = STATE_PAUSED self._status_state = "Paused" elif self._state["state"] == 4: - self._clean_state = STATE_ERROR + self._attr_state = STATE_ERROR self._status_state = ERRORS.get(self._state["error"]) self._attr_battery_level = self._state["details"]["charge"] @@ -261,11 +260,6 @@ def update(self) -> None: self._robot_boundaries, ) - @property - def state(self) -> str | None: - """Return the status of the vacuum cleaner.""" - return self._clean_state - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the vacuum cleaner.""" @@ -299,7 +293,7 @@ def extra_state_attributes(self) -> dict[str, Any]: @property def device_info(self) -> DeviceInfo: """Device info for neato robot.""" - device_info = super().device_info + device_info = self._attr_device_info if self._robot_stats: device_info["manufacturer"] = self._robot_stats["battery"]["vendor"] device_info["model"] = self._robot_stats["model"] @@ -331,9 +325,9 @@ def pause(self) -> None: def return_to_base(self, **kwargs: Any) -> None: """Set the vacuum cleaner to return to the dock.""" try: - if self._clean_state == STATE_CLEANING: + if self._attr_state == STATE_CLEANING: self.robot.pause_cleaning() - self._clean_state = STATE_RETURNING + self._attr_state = STATE_RETURNING self.robot.send_to_base() except NeatoRobotException as ex: _LOGGER.error( @@ -383,7 +377,7 @@ def neato_custom_cleaning( return _LOGGER.info("Start cleaning zone '%s' with robot %s", zone, self.entity_id) - self._clean_state = STATE_CLEANING + self._attr_state = STATE_CLEANING try: self.robot.start_cleaning(mode, navigation, category, boundary_id) except NeatoRobotException as ex: From 035fea3ee00e6a41fb7e11024813688d0fb8f039 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 09:40:25 -0500 Subject: [PATCH 123/640] Replace lambda with attrgetter in hassfest (#99662) --- script/hassfest/__main__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/hassfest/__main__.py b/script/hassfest/__main__.py index 1c626ac3c5b88f..32803731ecd8e6 100644 --- a/script/hassfest/__main__.py +++ b/script/hassfest/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations import argparse +from operator import attrgetter import pathlib import sys from time import monotonic @@ -229,7 +230,7 @@ def print_integrations_status( show_fixable_errors: bool = True, ) -> None: """Print integration status.""" - for integration in sorted(integrations, key=lambda itg: itg.domain): + for integration in sorted(integrations, key=attrgetter("domain")): extra = f" - {integration.path}" if config.specific_integrations else "" print(f"Integration {integration.domain}{extra}:") for error in integration.errors: From a04c61e77bd4adb1a6096a08f572f89bca9faf89 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 09:41:35 -0500 Subject: [PATCH 124/640] Replace lambda with attrgetter in device_tracker device_trigger (#99663) --- homeassistant/components/device_tracker/device_trigger.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/device_tracker/device_trigger.py b/homeassistant/components/device_tracker/device_trigger.py index a96f9affb1dd32..404dad0d4d1cc2 100644 --- a/homeassistant/components/device_tracker/device_trigger.py +++ b/homeassistant/components/device_tracker/device_trigger.py @@ -1,6 +1,7 @@ """Provides device automations for Device Tracker.""" from __future__ import annotations +from operator import attrgetter from typing import Final import voluptuous as vol @@ -98,7 +99,7 @@ async def async_get_trigger_capabilities( """List trigger capabilities.""" zones = { ent.entity_id: ent.name - for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=lambda ent: ent.name) + for ent in sorted(hass.states.async_all(DOMAIN_ZONE), key=attrgetter("name")) } return { "extra_fields": vol.Schema( From abb0537928cc36f810f887c02ea800c7679af54a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 10:36:01 -0500 Subject: [PATCH 125/640] Replace lambda with itemgetter in script/gen_requirements_all.py (#99661) --- script/gen_requirements_all.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 101a57e419d780..81fea80efadd69 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -4,6 +4,7 @@ import difflib import importlib +from operator import itemgetter import os from pathlib import Path import pkgutil @@ -333,7 +334,7 @@ def process_requirements( def generate_requirements_list(reqs: dict[str, list[str]]) -> str: """Generate a pip file based on requirements.""" output = [] - for pkg, requirements in sorted(reqs.items(), key=lambda item: item[0]): + for pkg, requirements in sorted(reqs.items(), key=itemgetter(0)): for req in sorted(requirements): output.append(f"\n# {req}") From e9062bb1b3c55d3372bada2d0a8de8e5f158c8e4 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 10:36:43 -0500 Subject: [PATCH 126/640] Replace lambda with attrgetter in homekit_controller (#99666) --- homeassistant/components/homekit_controller/connection.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py index 3e5fd4655d667e..348dd5e7ccfe06 100644 --- a/homeassistant/components/homekit_controller/connection.py +++ b/homeassistant/components/homekit_controller/connection.py @@ -5,6 +5,7 @@ from collections.abc import Callable, Iterable from datetime import datetime, timedelta import logging +from operator import attrgetter from types import MappingProxyType from typing import Any @@ -508,9 +509,7 @@ def async_create_devices(self) -> None: # Accessories need to be created in the correct order or setting up # relationships with ATTR_VIA_DEVICE may fail. - for accessory in sorted( - self.entity_map.accessories, key=lambda accessory: accessory.aid - ): + for accessory in sorted(self.entity_map.accessories, key=attrgetter("aid")): device_info = self.device_info_for_accessory(accessory) device = device_registry.async_get_or_create( From 5ccf866e228adbe332c5d43315b301145b31b52e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 5 Sep 2023 19:01:51 +0200 Subject: [PATCH 127/640] Use shorthand attributes for Plaato (#99634) Co-authored-by: Robert Resch --- homeassistant/components/plaato/entity.py | 35 ++++++++--------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/plaato/entity.py b/homeassistant/components/plaato/entity.py index 755ff8d2ae7369..b7650567c2bb53 100644 --- a/homeassistant/components/plaato/entity.py +++ b/homeassistant/components/plaato/entity.py @@ -30,7 +30,18 @@ def __init__(self, data, sensor_type, coordinator=None): self._device_id = data[DEVICE][DEVICE_ID] self._device_type = data[DEVICE][DEVICE_TYPE] self._device_name = data[DEVICE][DEVICE_NAME] - self._state = 0 + self._attr_unique_id = f"{self._device_id}_{self._sensor_type}" + self._attr_name = f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() + sw_version = None + if firmware := self._sensor_data.firmware_version: + sw_version = firmware + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer="Plaato", + model=self._device_type, + name=self._device_name, + sw_version=sw_version, + ) @property def _attributes(self) -> dict: @@ -46,28 +57,6 @@ def _sensor_data(self) -> PlaatoDevice: return self._coordinator.data return self._entry_data[SENSOR_DATA] - @property - def name(self): - """Return the name of the sensor.""" - return f"{DOMAIN} {self._device_type} {self._device_name} {self._sensor_name}".title() - - @property - def unique_id(self): - """Return the unique ID of this sensor.""" - return f"{self._device_id}_{self._sensor_type}" - - @property - def device_info(self) -> DeviceInfo: - """Get device info.""" - sw_version = self._sensor_data.firmware_version - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer="Plaato", - model=self._device_type, - name=self._device_name, - sw_version=sw_version if sw_version != "" else None, - ) - @property def extra_state_attributes(self): """Return the state attributes of the monitored installation.""" From 7fbb1c0fb64870399fe6a5dbf78d3e43200402f2 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Tue, 5 Sep 2023 20:12:40 +0200 Subject: [PATCH 128/640] Update frontend to 20230905.0 (#99677) --- 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 156adfa73d2152..627b36a59b854a 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==20230904.0"] + "requirements": ["home-assistant-frontend==20230905.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index cf17cb9b9136f9..c4492c90e9cd7d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index e4f4ebd5885fb4..997d93bee16694 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7bcf9cf3f47123..d92313cf9f1d61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230904.0 +home-assistant-frontend==20230905.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 7ab1913ba4f5b8b28e26981155b499c9ae3c79a4 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 5 Sep 2023 14:30:28 -0400 Subject: [PATCH 129/640] Fix ZHA startup creating entities with non-unique IDs (#99679) * Make the ZHAGateway initialization restartable so entities are unique * Add a unit test --- homeassistant/components/zha/__init__.py | 4 +- homeassistant/components/zha/core/gateway.py | 12 ++--- tests/components/zha/test_init.py | 50 +++++++++++++++++++- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 1c4c3e776d064b..f9113ebaa907a9 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -134,7 +134,9 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - zha_gateway = ZHAGateway(hass, config, config_entry) + # Re-use the gateway object between ZHA reloads + if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None: + zha_gateway = ZHAGateway(hass, config, config_entry) try: await zha_gateway.async_initialize() diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 3abf1274f984e5..353bc6904d7954 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -149,6 +149,12 @@ def __init__( self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] @@ -191,12 +197,6 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( config=app_config, diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 24ee63fb3d5064..63ca10bbf91961 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -1,8 +1,10 @@ """Tests for ZHA integration init.""" +import asyncio from unittest.mock import AsyncMock, Mock, patch import pytest from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH +from zigpy.exceptions import TransientConnectionError from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( @@ -11,10 +13,13 @@ CONF_USB_PATH, DOMAIN, ) -from homeassistant.const import MAJOR_VERSION, MINOR_VERSION +from homeassistant.const import MAJOR_VERSION, MINOR_VERSION, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers.event import async_call_later from homeassistant.setup import async_setup_component +from .test_light import LIGHT_ON_OFF + from tests.common import MockConfigEntry DATA_RADIO_TYPE = "deconz" @@ -157,3 +162,46 @@ async def test_setup_with_v3_cleaning_uri( assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path assert config_entry_v3.version == 3 + + +@patch( + "homeassistant.components.zha.PLATFORMS", + [Platform.LIGHT, Platform.BUTTON, Platform.SENSOR, Platform.SELECT], +) +async def test_zha_retry_unique_ids( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + caplog, +) -> None: + """Test that ZHA retrying creates unique entity IDs.""" + + config_entry.add_to_hass(hass) + + # Ensure we have some device to try to load + app = mock_zigpy_connect.return_value + light = zigpy_device_mock(LIGHT_ON_OFF) + app.devices[light.ieee] = light + + # Re-try setup but have it fail once, so entities have two chances to be created + with patch.object( + app, + "startup", + side_effect=[TransientConnectionError(), None], + ) as mock_connect: + with patch( + "homeassistant.config_entries.async_call_later", + lambda hass, delay, action: async_call_later(hass, 0, action), + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Wait for the config entry setup to retry + await asyncio.sleep(0.1) + + assert len(mock_connect.mock_calls) == 2 + + await hass.config_entries.async_unload(config_entry.entry_id) + + assert "does not generate unique IDs" not in caplog.text From b0e40d95adaba4bf5cfacf401ff7783dcd39313c Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Tue, 5 Sep 2023 21:13:28 +0200 Subject: [PATCH 130/640] Bump aiounifi to v61 (#99686) * Bump aiounifi to v61 * Alter a test to cover the upstream change --- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/unifi/test_sensor.py | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index cb1c8f1c0dc1c1..f20e5f9e4acabd 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==60"], + "requirements": ["aiounifi==61"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 997d93bee16694..ee6c41d839a2cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==60 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d92313cf9f1d61..e89813d102e04c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==60 +aiounifi==61 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index da2c0b46f763ec..7ed87512f2bad7 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -336,8 +336,8 @@ async def test_bandwidth_sensors( "mac": "00:00:00:00:00:02", "name": "Wireless client", "oui": "Producer", - "rx_bytes-r": 2345000000, - "tx_bytes-r": 6789000000, + "rx_bytes-r": 2345000000.0, + "tx_bytes-r": 6789000000.0, } options = { CONF_ALLOW_BANDWIDTH_SENSORS: True, From a9c41f76e3fbd6085857750928a3eb2de531bcad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Tue, 5 Sep 2023 21:14:39 +0200 Subject: [PATCH 131/640] Bump millheater to 0.11.2 (#99683) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update Mill lib Signed-off-by: Daniel Hjelseth Høyer --- 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 39b91570190c0e..a1538bed5cf832 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.1", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.2", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index ee6c41d839a2cf..915d3eba8f6094 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1215,7 +1215,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.2 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e89813d102e04c..2afa9594b64369 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -932,7 +932,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.1 +millheater==0.11.2 # homeassistant.components.minio minio==7.1.12 From 2cf25ee9ec9ea997b1e9d01204b3a5a824997238 Mon Sep 17 00:00:00 2001 From: Daniel Gangl <31815106+killer0071234@users.noreply.github.com> Date: Tue, 5 Sep 2023 21:18:06 +0200 Subject: [PATCH 132/640] Bump zamg to 0.3.0 (#99685) --- homeassistant/components/zamg/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zamg/manifest.json b/homeassistant/components/zamg/manifest.json index 3ff7612d47ebec..df17672231e15f 100644 --- a/homeassistant/components/zamg/manifest.json +++ b/homeassistant/components/zamg/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/zamg", "iot_class": "cloud_polling", - "requirements": ["zamg==0.2.4"] + "requirements": ["zamg==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 915d3eba8f6094..485d44b9df6dab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2762,7 +2762,7 @@ youtubeaio==1.1.5 yt-dlp==2023.7.6 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zengge zengge==0.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2afa9594b64369..ffa89229563b32 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2038,7 +2038,7 @@ youless-api==1.0.1 youtubeaio==1.1.5 # homeassistant.components.zamg -zamg==0.2.4 +zamg==0.3.0 # homeassistant.components.zeroconf zeroconf==0.97.0 From 3f3d8b1e1e0ebb319b70b1aa01ebeab3bf921be6 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 5 Sep 2023 21:21:27 +0200 Subject: [PATCH 133/640] Bump reolink_aio to 0.7.9 (#99680) --- 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 3ff25d1e7a0910..060490c6e565be 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.8"] + "requirements": ["reolink-aio==0.7.9"] } diff --git a/requirements_all.txt b/requirements_all.txt index 485d44b9df6dab..98e597ae6e32f7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2296,7 +2296,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ffa89229563b32..a2999d0fd07548 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1689,7 +1689,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.8 +reolink-aio==0.7.9 # homeassistant.components.rflink rflink==0.0.65 From c64d173fcb853511f4ddeec0da760f9aec8214d8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 5 Sep 2023 21:50:51 +0200 Subject: [PATCH 134/640] Remove imap_email_content integration (#99484) --- .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 d28878d8861fd4..f2231ea31c2eeb 100644 --- a/.coveragerc +++ b/.coveragerc @@ -547,7 +547,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 4c4a2e2a35c5ef..70594d5fd7cd5a 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,13 +10,7 @@ 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 f2041b947df6d6..00000000000000 --- 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 5f1c653030e568..00000000000000 --- 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 b7d0589b83f201..00000000000000 --- 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 f19b0499040b0a..00000000000000 --- 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.10.0", - is_fixable=True, - severity=ir.IssueSeverity.WARNING, - translation_key="migration", - translation_placeholders=placeholders, - data=data, - ) - - -class DeprecationRepairFlow(RepairsFlow): - """Handler for an issue fixing flow.""" - - def __init__(self, issue_id: str, config: ConfigType) -> None: - """Create flow.""" - self._name: str = config[CONF_NAME] - self._config: dict[str, Any] = config - self._issue_id = issue_id - super().__init__() - - async def async_step_init( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the first step of a fix flow.""" - return await self.async_step_start() - - @callback - def _async_get_placeholders(self) -> dict[str, str] | None: - issue_registry = ir.async_get(self.hass) - description_placeholders = None - if issue := issue_registry.async_get_issue(self.handler, self.issue_id): - description_placeholders = issue.translation_placeholders - - return description_placeholders - - async def async_step_start( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Wait for the user to start the config migration.""" - placeholders = self._async_get_placeholders() - if user_input is None: - return self.async_show_form( - step_id="start", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - return await self.async_step_confirm() - - async def async_step_confirm( - self, user_input: dict[str, str] | None = None - ) -> data_entry_flow.FlowResult: - """Handle the confirm step of a fix flow.""" - placeholders = self._async_get_placeholders() - if user_input is not None: - user_input[CONF_NAME] = self._name - result = await self.hass.config_entries.flow.async_init( - IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config - ) - if result["type"] == FlowResultType.ABORT: - ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) - ir.async_create_issue( - self.hass, - DOMAIN, - self._issue_id, - breaks_in_ha_version="2023.10.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecation", - translation_placeholders=placeholders, - data=self._config, - learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", - ) - return self.async_abort(reason=result["reason"]) - return self.async_create_entry( - title="", - data={}, - ) - - return self.async_show_form( - step_id="confirm", - data_schema=vol.Schema({}), - description_placeholders=placeholders, - ) - - -async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str | int | float | None], -) -> RepairsFlow: - """Create flow.""" - return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py deleted file mode 100644 index 1df207e29687a8..00000000000000 --- 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 b7b987b1212cb1..00000000000000 --- 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 a9e19441693b57..acf12b4f05dc0b 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2581,12 +2581,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 efb505cda774db..d36cffbce0682d 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 2c7e569236655c..00000000000000 --- 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 6323dcde3776b3..00000000000000 --- 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 3e8a6c1e28299e..00000000000000 --- 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 a1359c1ce32d8de5c3fe5ef99fffb52736a2a74b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 15:44:59 -0500 Subject: [PATCH 135/640] Replace lambda in script/gen_requirements_all.py with str.lower (#99665) --- script/gen_requirements_all.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 81fea80efadd69..7d587d761ecfcd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -426,7 +426,7 @@ def gather_constraints() -> str: *gather_recursive_requirements("default_config"), *gather_recursive_requirements("mqtt"), }, - key=lambda name: name.lower(), + key=str.lower, ) + [""] ) From b69cc29a78714ef465c4aefb97ca08e011cce8fb Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 15:45:45 -0500 Subject: [PATCH 136/640] Switch lambda to attrgetter in zha (#99660) --- homeassistant/components/zha/core/registries.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 03fdc7e37c1f0e..713d10ddf704d8 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -4,6 +4,7 @@ import collections from collections.abc import Callable import dataclasses +from operator import attrgetter from typing import TYPE_CHECKING, TypeVar import attr @@ -111,6 +112,8 @@ ] = DictRegistry() ZIGBEE_CLUSTER_HANDLER_REGISTRY: DictRegistry[type[ClusterHandler]] = DictRegistry() +WEIGHT_ATTR = attrgetter("weight") + def set_or_callable(value) -> frozenset[str] | Callable: """Convert single str or None to a set. Pass through callables and sets.""" @@ -294,7 +297,7 @@ def get_entity( ) -> 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=lambda x: x.weight, reverse=True): + for match in sorted(matches, key=WEIGHT_ATTR, reverse=True): if match.strict_matched(manufacturer, model, cluster_handlers, quirk_class): claimed = match.claim_cluster_handlers(cluster_handlers) return self._strict_registry[component][match], claimed @@ -315,7 +318,7 @@ def get_multi_entity( all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class @@ -349,7 +352,7 @@ def get_config_diagnostic_entity( stop_match_groups, ) in self._config_diagnostic_entity_registry.items(): for stop_match_grp, matches in stop_match_groups.items(): - sorted_matches = sorted(matches, key=lambda x: x.weight, reverse=True) + sorted_matches = sorted(matches, key=WEIGHT_ATTR, reverse=True) for match in sorted_matches: if match.strict_matched( manufacturer, model, cluster_handlers, quirk_class From a2dae601708ff235ff1239d7c9e51afcadd07fe0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:18:27 -0500 Subject: [PATCH 137/640] Refactor dispatcher to reduce run time and memory overhead (#99676) * Fix memory leak in dispatcher removal When we removed the last job/callable from the dict for the signal we did not remove the dict for the signal which meant it leaked * comment * cleanup a bit more --- homeassistant/helpers/dispatcher.py | 69 +++++++++++++++++++---------- tests/helpers/test_dispatcher.py | 22 +++++++++ 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/homeassistant/helpers/dispatcher.py b/homeassistant/helpers/dispatcher.py index 60aab156144f66..e416d939914b69 100644 --- a/homeassistant/helpers/dispatcher.py +++ b/homeassistant/helpers/dispatcher.py @@ -2,6 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine +from functools import partial import logging from typing import Any @@ -13,6 +14,14 @@ _LOGGER = logging.getLogger(__name__) DATA_DISPATCHER = "dispatcher" +_DispatcherDataType = dict[ + str, + dict[ + Callable[..., Any], + HassJob[..., None | Coroutine[Any, Any, None]] | None, + ], +] + @bind_hass def dispatcher_connect( @@ -30,6 +39,26 @@ def remove_dispatcher() -> None: return remove_dispatcher +@callback +def _async_remove_dispatcher( + dispatchers: _DispatcherDataType, + signal: str, + target: Callable[..., Any], +) -> None: + """Remove signal listener.""" + try: + signal_dispatchers = dispatchers[signal] + del signal_dispatchers[target] + # Cleanup the signal dict if it is now empty + # to prevent memory leaks + if not signal_dispatchers: + del dispatchers[signal] + except (KeyError, ValueError): + # KeyError is key target listener did not exist + # ValueError if listener did not exist within signal + _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + + @callback @bind_hass def async_dispatcher_connect( @@ -41,19 +70,18 @@ def async_dispatcher_connect( """ if DATA_DISPATCHER not in hass.data: hass.data[DATA_DISPATCHER] = {} - hass.data[DATA_DISPATCHER].setdefault(signal, {})[target] = None - @callback - def async_remove_dispatcher() -> None: - """Remove signal listener.""" - try: - del hass.data[DATA_DISPATCHER][signal][target] - except (KeyError, ValueError): - # KeyError is key target listener did not exist - # ValueError if listener did not exist within signal - _LOGGER.warning("Unable to remove unknown dispatcher %s", target) + dispatchers: _DispatcherDataType = hass.data[DATA_DISPATCHER] + + if signal not in dispatchers: + dispatchers[signal] = {} - return async_remove_dispatcher + dispatchers[signal][target] = None + # Use a partial for the remove since it uses + # less memory than a full closure since a partial copies + # the body of the function and we don't have to store + # many different copies of the same function + return partial(_async_remove_dispatcher, dispatchers, signal, target) @bind_hass @@ -87,21 +115,14 @@ def async_dispatcher_send(hass: HomeAssistant, signal: str, *args: Any) -> None: This method must be run in the event loop. """ - target_list: dict[ - Callable[..., Any], HassJob[..., None | Coroutine[Any, Any, None]] | None - ] = hass.data.get(DATA_DISPATCHER, {}).get(signal, {}) + if (maybe_dispatchers := hass.data.get(DATA_DISPATCHER)) is None: + return + dispatchers: _DispatcherDataType = maybe_dispatchers + if (target_list := dispatchers.get(signal)) is None: + return - run: list[HassJob[..., None | Coroutine[Any, Any, None]]] = [] - for target, job in target_list.items(): + for target, job in list(target_list.items()): if job is None: job = _generate_job(signal, target) target_list[target] = job - - # Run the jobs all at the end - # to ensure no jobs add more dispatchers - # which can result in the target_list - # changing size during iteration - run.append(job) - - for job in run: hass.async_run_hass_job(job, *args) diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index e30aaa6e0d9e1c..a251b20b0f41ad 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -151,3 +151,25 @@ def bad_handler(*args): f"Exception in functools.partial({bad_handler}) when dispatching 'test': ('bad',)" in caplog.text ) + + +async def test_dispatcher_add_dispatcher(hass: HomeAssistant) -> None: + """Test adding a dispatcher from a dispatcher.""" + calls = [] + + @callback + def _new_dispatcher(data): + calls.append(data) + + @callback + def _add_new_dispatcher(data): + calls.append(data) + async_dispatcher_connect(hass, "test", _new_dispatcher) + + async_dispatcher_connect(hass, "test", _add_new_dispatcher) + + async_dispatcher_send(hass, "test", 3) + async_dispatcher_send(hass, "test", 4) + async_dispatcher_send(hass, "test", 5) + + assert calls == [3, 4, 4, 5, 5] From e22b03d6b3a84c66d35d411367d35536d76a1936 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:18:46 -0500 Subject: [PATCH 138/640] Switch homekit config flow sorted to use itemgetter (#99658) Avoids unnecessary lambda --- homeassistant/components/homekit/config_flow.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/config_flow.py b/homeassistant/components/homekit/config_flow.py index 3747af3edc7d68..c43093d92b49b6 100644 --- a/homeassistant/components/homekit/config_flow.py +++ b/homeassistant/components/homekit/config_flow.py @@ -3,6 +3,7 @@ from collections.abc import Iterable from copy import deepcopy +from operator import itemgetter import random import re import string @@ -638,7 +639,7 @@ async def _async_get_supported_devices(hass: HomeAssistant) -> dict[str, str]: for device_id in results: entry = dev_reg.async_get(device_id) unsorted[device_id] = entry.name or device_id if entry else device_id - return dict(sorted(unsorted.items(), key=lambda item: item[1])) + return dict(sorted(unsorted.items(), key=itemgetter(1))) def _exclude_by_entity_registry( From da45f6cbb03f6c3fb96a3cc202e90b6009f040e6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 5 Sep 2023 20:42:50 -0500 Subject: [PATCH 139/640] Bump aioesphomeapi to 16.0.5 (#99698) changelog: https://github.com/esphome/aioesphomeapi/compare/v16.0.4...v16.0.5 fixes `RuntimeError: set changed size during iteration` https://github.com/esphome/aioesphomeapi/pull/538 some added debug logging which may help with https://github.com/home-assistant/core/issues/98221 --- 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 32d915f8b769f3..e311a0913aeacc 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==16.0.4", + "aioesphomeapi==16.0.5", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" ], diff --git a/requirements_all.txt b/requirements_all.txt index 98e597ae6e32f7..c900fb37c381ca 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -232,7 +232,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.4 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a2999d0fd07548..f3d8fc5d322660 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -213,7 +213,7 @@ aioecowitt==2023.5.0 aioemonitor==1.0.5 # homeassistant.components.esphome -aioesphomeapi==16.0.4 +aioesphomeapi==16.0.5 # homeassistant.components.flo aioflo==2021.11.0 From 4f05e610728a6e61a74db36357db480f311087db Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 04:14:56 +0200 Subject: [PATCH 140/640] Add codeowner for Withings (#99681) --- CODEOWNERS | 4 ++-- homeassistant/components/withings/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 42537d4e3f18c4..79ff912f4b7603 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1416,8 +1416,8 @@ build.json @home-assistant/supervisor /homeassistant/components/wilight/ @leofig-rj /tests/components/wilight/ @leofig-rj /homeassistant/components/wirelesstag/ @sergeymaysak -/homeassistant/components/withings/ @vangorra -/tests/components/withings/ @vangorra +/homeassistant/components/withings/ @vangorra @joostlek +/tests/components/withings/ @vangorra @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 29201c7e66ef84..325205cb4d4c0f 100644 --- a/homeassistant/components/withings/manifest.json +++ b/homeassistant/components/withings/manifest.json @@ -1,7 +1,7 @@ { "domain": "withings", "name": "Withings", - "codeowners": ["@vangorra"], + "codeowners": ["@vangorra", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials", "http", "webhook"], "documentation": "https://www.home-assistant.io/integrations/withings", From d9a1ebafddc32ab8efcccc2ab5de3d3424569188 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 6 Sep 2023 06:17:45 +0000 Subject: [PATCH 141/640] Show OTA update progress for Shelly gen2 devices (#99534) * Show OTA update progress * Use an event listener instead of a dispatcher * Add tests * Fix name * Improve tests coverage * Fix subscribe/unsubscribe logic * Use async_on_remove() --- homeassistant/components/shelly/const.py | 5 + .../components/shelly/coordinator.py | 21 ++++ homeassistant/components/shelly/update.py | 34 +++-- tests/components/shelly/test_update.py | 119 ++++++++++++++++-- 4 files changed, 159 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 33b4caa5034aaa..0275b8052085b3 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -181,3 +181,8 @@ class BLEScannerMode(StrEnum): NOT_CALIBRATED_ISSUE_ID = "not_calibrated_{unique}" GAS_VALVE_OPEN_STATES = ("opening", "opened") + +OTA_BEGIN = "ota_begin" +OTA_ERROR = "ota_error" +OTA_PROGRESS = "ota_progress" +OTA_SUCCESS = "ota_success" diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d645b09799f64c..d0530efa149d11 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -44,6 +44,10 @@ LOGGER, MAX_PUSH_UPDATE_FAILURES, MODELS_SUPPORTING_LIGHT_EFFECTS, + OTA_BEGIN, + OTA_ERROR, + OTA_PROGRESS, + OTA_SUCCESS, PUSH_UPDATE_ISSUE_ID, REST_SENSORS_UPDATE_INTERVAL, RPC_INPUTS_EVENTS_TYPES, @@ -384,6 +388,7 @@ def __init__( self._disconnected_callbacks: list[CALLBACK_TYPE] = [] self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @@ -408,6 +413,19 @@ def update_sleep_period(self) -> bool: return True + @callback + def async_subscribe_ota_events( + self, ota_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to OTA events.""" + + def _unsubscribe() -> None: + self._ota_event_listeners.remove(ota_event_callback) + + self._ota_event_listeners.append(ota_event_callback) + + return _unsubscribe + @callback def async_subscribe_events( self, event_callback: Callable[[dict[str, Any]], None] @@ -461,6 +479,9 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: ATTR_GENERATION: 2, }, ) + elif event_type in (OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS): + for event_callback in self._ota_event_listeners: + event_callback(event) async def _async_update_data(self) -> None: """Fetch data.""" diff --git a/homeassistant/components/shelly/update.py b/homeassistant/components/shelly/update.py index 3b2096f0c1a997..d4528f552885d0 100644 --- a/homeassistant/components/shelly/update.py +++ b/homeassistant/components/shelly/update.py @@ -18,12 +18,12 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from .const import CONF_SLEEP_PERIOD +from .const import CONF_SLEEP_PERIOD, OTA_BEGIN, OTA_ERROR, OTA_PROGRESS, OTA_SUCCESS from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator from .entity import ( RestEntityDescription, @@ -229,7 +229,28 @@ def __init__( ) -> None: """Initialize update entity.""" super().__init__(coordinator, key, attribute, description) - self._in_progress_old_version: str | None = None + self._ota_in_progress: bool = False + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_ota_events(self._ota_progress_callback) + ) + + @callback + def _ota_progress_callback(self, event: dict[str, Any]) -> None: + """Handle device OTA progress.""" + if self._ota_in_progress: + event_type = event["event"] + if event_type == OTA_BEGIN: + self._attr_in_progress = 0 + elif event_type == OTA_PROGRESS: + self._attr_in_progress = event["progress_percent"] + elif event_type in (OTA_ERROR, OTA_SUCCESS): + self._attr_in_progress = False + self._ota_in_progress = False + self.async_write_ha_state() @property def installed_version(self) -> str | None: @@ -245,16 +266,10 @@ def latest_version(self) -> str | None: return self.installed_version - @property - def in_progress(self) -> bool: - """Update installation in progress.""" - return self._in_progress_old_version == self.installed_version - async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install the latest firmware version.""" - self._in_progress_old_version = self.installed_version beta = self.entity_description.beta update_data = self.coordinator.device.status["sys"]["available_updates"] LOGGER.debug("OTA update service - update_data: %s", update_data) @@ -280,6 +295,7 @@ async def async_install( except InvalidAuthError: self.coordinator.entry.async_start_reauth(self.hass) else: + self._ota_in_progress = True LOGGER.debug("OTA update call successful") diff --git a/tests/components/shelly/test_update.py b/tests/components/shelly/test_update.py index 1ff2ac99814c24..454afb73ce15bd 100644 --- a/tests/components/shelly/test_update.py +++ b/tests/components/shelly/test_update.py @@ -29,6 +29,7 @@ from . import ( MOCK_MAC, init_integration, + inject_rpc_device_event, mock_rest_update, register_device, register_entity, @@ -222,6 +223,7 @@ async def test_block_update_auth_error( async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: """Test RPC device update entity.""" + entity_id = "update.test_name_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -232,7 +234,7 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -243,21 +245,68 @@ async def test_rpc_update(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 50, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 50 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2") mock_rpc_device.mock_update() - state = hass.states.get("update.test_name_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2" assert state.attributes[ATTR_LATEST_VERSION] == "2" @@ -401,6 +450,7 @@ async def test_rpc_beta_update( suggested_object_id="test_name_beta_firmware_update", disabled_by=None, ) + entity_id = "update.test_name_beta_firmware_update" monkeypatch.setitem(mock_rpc_device.shelly, "ver", "1") monkeypatch.setitem( mock_rpc_device.status["sys"], @@ -412,7 +462,7 @@ async def test_rpc_beta_update( ) await init_integration(hass, 2) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "1" @@ -428,7 +478,7 @@ async def test_rpc_beta_update( ) await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" @@ -437,21 +487,68 @@ async def test_rpc_beta_update( await hass.services.async_call( UPDATE_DOMAIN, SERVICE_INSTALL, - {ATTR_ENTITY_ID: "update.test_name_beta_firmware_update"}, + {ATTR_ENTITY_ID: entity_id}, blocking=True, ) + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_begin", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + assert mock_rpc_device.trigger_ota_update.call_count == 1 - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_ON assert state.attributes[ATTR_INSTALLED_VERSION] == "1" assert state.attributes[ATTR_LATEST_VERSION] == "2b" - assert state.attributes[ATTR_IN_PROGRESS] is True + assert state.attributes[ATTR_IN_PROGRESS] == 0 + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_progress", + "id": 1, + "ts": 1668522399.2, + "progress_percent": 40, + } + ], + "ts": 1668522399.2, + }, + ) + + assert hass.states.get(entity_id).attributes[ATTR_IN_PROGRESS] == 40 + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "ota_success", + "id": 1, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) monkeypatch.setitem(mock_rpc_device.shelly, "ver", "2b") await mock_rest_update(hass, freezer) - state = hass.states.get("update.test_name_beta_firmware_update") + state = hass.states.get(entity_id) assert state.state == STATE_OFF assert state.attributes[ATTR_INSTALLED_VERSION] == "2b" assert state.attributes[ATTR_LATEST_VERSION] == "2b" From d523734db1b60cc4707cf798faa90b0402b84d48 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Wed, 6 Sep 2023 09:35:34 +0300 Subject: [PATCH 142/640] Display channel number in Bravia TV if title is not available (#99567) Display channel number if title is not available --- homeassistant/components/braviatv/coordinator.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/braviatv/coordinator.py b/homeassistant/components/braviatv/coordinator.py index 9b89c667b3c08c..20b30d1dd11ee1 100644 --- a/homeassistant/components/braviatv/coordinator.py +++ b/homeassistant/components/braviatv/coordinator.py @@ -191,9 +191,11 @@ async def async_update_playing(self) -> None: if self.media_uri[:8] == "extInput": self.source = playing_info.get("title") if self.media_uri[:2] == "tv": - self.media_title = playing_info.get("programTitle") - self.media_channel = playing_info.get("title") self.media_content_id = playing_info.get("dispNum") + self.media_title = ( + playing_info.get("programTitle") or self.media_content_id + ) + self.media_channel = playing_info.get("title") or self.media_content_id self.media_content_type = MediaType.CHANNEL if not playing_info: self.media_title = "Smart TV" From d4ef570b0a40907a4ca15e0def185fc7471c1cfe Mon Sep 17 00:00:00 2001 From: tronikos Date: Tue, 5 Sep 2023 23:43:46 -0700 Subject: [PATCH 143/640] Add a comment why state_class=total (#99703) --- homeassistant/components/opower/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/opower/sensor.py b/homeassistant/components/opower/sensor.py index 6be74deaebf734..175bef01449959 100644 --- a/homeassistant/components/opower/sensor.py +++ b/homeassistant/components/opower/sensor.py @@ -45,6 +45,7 @@ class OpowerEntityDescription(SensorEntityDescription, OpowerEntityDescriptionMi name="Current bill electric usage to date", device_class=SensorDeviceClass.ENERGY, native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + # Not TOTAL_INCREASING because it can decrease for accounts with solar state_class=SensorStateClass.TOTAL, suggested_display_precision=0, value_fn=lambda data: data.usage_to_date, From b28fda2433cf911ff64b761f7490d50e3ae386bf Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 08:54:25 +0200 Subject: [PATCH 144/640] Move template coordinator to its own file (#99419) * Move template update coordinator to its own file * Add coordinator.py to .coveragerc * Remove coordinator.py to .coveragerc * Apply suggestions from code review * Update homeassistant/components/template/coordinator.py * Copy over fixes from upstream --------- Co-authored-by: Erik Montnemery Co-authored-by: G Johansson --- homeassistant/components/template/__init__.py | 99 +------------------ .../components/template/coordinator.py | 94 ++++++++++++++++++ 2 files changed, 99 insertions(+), 94 deletions(-) create mode 100644 homeassistant/components/template/coordinator.py diff --git a/homeassistant/components/template/__init__.py b/homeassistant/components/template/__init__.py index c4ba7081f5a76a..22919ac9e708e1 100644 --- a/homeassistant/components/template/__init__.py +++ b/homeassistant/components/template/__init__.py @@ -2,30 +2,21 @@ from __future__ import annotations import asyncio -from collections.abc import Callable import logging from homeassistant import config as conf_util from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONF_UNIQUE_ID, - EVENT_HOMEASSISTANT_START, - SERVICE_RELOAD, -) -from homeassistant.core import CoreState, Event, HomeAssistant, ServiceCall, callback +from homeassistant.const import CONF_UNIQUE_ID, SERVICE_RELOAD +from homeassistant.core import Event, HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import ( - discovery, - trigger as trigger_helper, - update_coordinator, -) +from homeassistant.helpers import discovery from homeassistant.helpers.reload import async_reload_integration_platforms -from homeassistant.helpers.script import Script from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.typing import ConfigType from homeassistant.loader import async_get_integration -from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS +from .const import CONF_TRIGGER, DOMAIN, PLATFORMS +from .coordinator import TriggerUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -121,83 +112,3 @@ async def init_coordinator(hass, conf_section): if coordinator_tasks: hass.data[DOMAIN] = await asyncio.gather(*coordinator_tasks) - - -class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): - """Class to handle incoming data.""" - - REMOVE_TRIGGER = object() - - def __init__(self, hass, config): - """Instantiate trigger data.""" - super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") - self.config = config - self._unsub_start: Callable[[], None] | None = None - self._unsub_trigger: Callable[[], None] | None = None - self._script: Script | None = None - - @property - def unique_id(self) -> str | None: - """Return unique ID for the entity.""" - return self.config.get("unique_id") - - @callback - def async_remove(self): - """Signal that the entities need to remove themselves.""" - if self._unsub_start: - self._unsub_start() - if self._unsub_trigger: - self._unsub_trigger() - - async def async_setup(self, hass_config: ConfigType) -> None: - """Set up the trigger and create entities.""" - if self.hass.state == CoreState.running: - await self._attach_triggers() - else: - self._unsub_start = self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._attach_triggers - ) - - for platform_domain in PLATFORMS: - if platform_domain in self.config: - self.hass.async_create_task( - discovery.async_load_platform( - self.hass, - platform_domain, - DOMAIN, - {"coordinator": self, "entities": self.config[platform_domain]}, - hass_config, - ) - ) - - async def _attach_triggers(self, start_event=None) -> None: - """Attach the triggers.""" - if CONF_ACTION in self.config: - self._script = Script( - self.hass, - self.config[CONF_ACTION], - self.name, - DOMAIN, - ) - - if start_event is not None: - self._unsub_start = None - - self._unsub_trigger = await trigger_helper.async_initialize_triggers( - self.hass, - self.config[CONF_TRIGGER], - self._handle_triggered, - DOMAIN, - self.name, - self.logger.log, - start_event is not None, - ) - - async def _handle_triggered(self, run_variables, context=None): - if self._script: - script_result = await self._script.async_run(run_variables, context) - if script_result: - run_variables = script_result.variables - self.async_set_updated_data( - {"run_variables": run_variables, "context": context} - ) diff --git a/homeassistant/components/template/coordinator.py b/homeassistant/components/template/coordinator.py new file mode 100644 index 00000000000000..7f24fe731cc024 --- /dev/null +++ b/homeassistant/components/template/coordinator.py @@ -0,0 +1,94 @@ +"""Data update coordinator for trigger based template entities.""" +from collections.abc import Callable +import logging + +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import CoreState, callback +from homeassistant.helpers import discovery, trigger as trigger_helper +from homeassistant.helpers.script import Script +from homeassistant.helpers.typing import ConfigType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_ACTION, CONF_TRIGGER, DOMAIN, PLATFORMS + +_LOGGER = logging.getLogger(__name__) + + +class TriggerUpdateCoordinator(DataUpdateCoordinator): + """Data update coordinator for trigger based template entities.""" + + REMOVE_TRIGGER = object() + + def __init__(self, hass, config): + """Instantiate trigger data.""" + super().__init__(hass, _LOGGER, name="Trigger Update Coordinator") + self.config = config + self._unsub_start: Callable[[], None] | None = None + self._unsub_trigger: Callable[[], None] | None = None + self._script: Script | None = None + + @property + def unique_id(self) -> str | None: + """Return unique ID for the entity.""" + return self.config.get("unique_id") + + @callback + def async_remove(self): + """Signal that the entities need to remove themselves.""" + if self._unsub_start: + self._unsub_start() + if self._unsub_trigger: + self._unsub_trigger() + + async def async_setup(self, hass_config: ConfigType) -> None: + """Set up the trigger and create entities.""" + if self.hass.state == CoreState.running: + await self._attach_triggers() + else: + self._unsub_start = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_START, self._attach_triggers + ) + + for platform_domain in PLATFORMS: + if platform_domain in self.config: + self.hass.async_create_task( + discovery.async_load_platform( + self.hass, + platform_domain, + DOMAIN, + {"coordinator": self, "entities": self.config[platform_domain]}, + hass_config, + ) + ) + + async def _attach_triggers(self, start_event=None) -> None: + """Attach the triggers.""" + if CONF_ACTION in self.config: + self._script = Script( + self.hass, + self.config[CONF_ACTION], + self.name, + DOMAIN, + ) + + if start_event is not None: + self._unsub_start = None + + self._unsub_trigger = await trigger_helper.async_initialize_triggers( + self.hass, + self.config[CONF_TRIGGER], + self._handle_triggered, + DOMAIN, + self.name, + self.logger.log, + start_event is not None, + ) + + async def _handle_triggered(self, run_variables, context=None): + if self._script: + script_result = await self._script.async_run(run_variables, context) + if script_result: + run_variables = script_result.variables + self.async_set_updated_data( + {"run_variables": run_variables, "context": context} + ) From cdca4591a4e25922f4d4af4c0c2a96256eafb35d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 09:49:42 +0200 Subject: [PATCH 145/640] Include template listener info in template preview (#99669) --- .../components/template/config_flow.py | 3 +- .../components/template/template_entity.py | 34 ++++++++++++++----- .../helpers/trigger_template_entity.py | 6 ++-- tests/components/template/test_config_flow.py | 27 +++++++++++++++ 4 files changed, 57 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index ccc06989c7116d..093cbf140983fb 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -349,6 +349,7 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An def async_preview_updated( state: str | None, attributes: Mapping[str, Any] | None, + listeners: dict[str, bool | set[str]] | None, error: str | None, ) -> None: """Forward config entry state events to websocket.""" @@ -363,7 +364,7 @@ def async_preview_updated( connection.send_message( websocket_api.event_message( msg["id"], - {"attributes": attributes, "state": state}, + {"attributes": attributes, "listeners": listeners, "state": state}, ) ) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index c33674fa86f25c..2ce42083117b9d 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -34,6 +34,7 @@ EventStateChangedData, TrackTemplate, TrackTemplateResult, + TrackTemplateResultInfo, async_track_template_result, ) from homeassistant.helpers.script import Script, _VarsType @@ -260,12 +261,18 @@ def __init__( ) -> None: """Template Entity.""" self._template_attrs: dict[Template, list[_TemplateAttribute]] = {} - self._async_update: Callable[[], None] | None = None + self._template_result_info: TrackTemplateResultInfo | None = None self._attr_extra_state_attributes = {} self._self_ref_update_count = 0 self._attr_unique_id = unique_id self._preview_callback: Callable[ - [str | None, dict[str, Any] | None, str | None], None + [ + str | None, + dict[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ] | None = None if config is None: self._attribute_templates = attribute_templates @@ -427,9 +434,12 @@ def _handle_results( state, attrs = self._async_generate_attributes() validate_state(state) except Exception as err: # pylint: disable=broad-exception-caught - self._preview_callback(None, None, str(err)) + self._preview_callback(None, None, None, str(err)) else: - self._preview_callback(state, attrs, None) + assert self._template_result_info + self._preview_callback( + state, attrs, self._template_result_info.listeners, None + ) @callback def _async_template_startup(self, *_: Any) -> None: @@ -460,7 +470,7 @@ def _async_template_startup(self, *_: Any) -> None: has_super_template=has_availability_template, ) self.async_on_remove(result_info.async_remove) - self._async_update = result_info.async_refresh + self._template_result_info = result_info result_info.async_refresh() @callback @@ -494,7 +504,13 @@ def _async_setup_templates(self) -> None: def async_start_preview( self, preview_callback: Callable[ - [str | None, Mapping[str, Any] | None, str | None], None + [ + str | None, + Mapping[str, Any] | None, + dict[str, bool | set[str]] | None, + str | None, + ], + None, ], ) -> CALLBACK_TYPE: """Render a preview.""" @@ -504,7 +520,7 @@ def async_start_preview( try: self._async_template_startup() except Exception as err: # pylint: disable=broad-exception-caught - preview_callback(None, None, str(err)) + preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks async def async_added_to_hass(self) -> None: @@ -521,8 +537,8 @@ async def async_added_to_hass(self) -> None: async def async_update(self) -> None: """Call for forced update.""" - assert self._async_update - self._async_update() + assert self._template_result_info + self._template_result_info.async_refresh() async def async_run_script( self, diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 8fc99f5cb52d46..0ee653b42bdfc6 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -77,8 +77,8 @@ class TriggerBaseEntity(Entity): """Template Base entity based on trigger data.""" domain: str - extra_template_keys: tuple | None = None - extra_template_keys_complex: tuple | None = None + extra_template_keys: tuple[str, ...] | None = None + extra_template_keys_complex: tuple[str, ...] | None = None _unique_id: str | None def __init__( @@ -94,7 +94,7 @@ def __init__( self._config = config self._static_rendered = {} - self._to_render_simple = [] + self._to_render_simple: list[str] = [] self._to_render_complex: list[str] = [] for itm in ( diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index ba939f3b8d1052..b8634b68b1c74d 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import patch import pytest +from pytest_unordered import unordered from homeassistant import config_entries from homeassistant.components.template import DOMAIN, async_setup_entry @@ -257,6 +258,7 @@ async def test_options( "input_states", "template_states", "extra_attributes", + "listeners", ), ( ( @@ -266,6 +268,7 @@ async def test_options( {"one": "on", "two": "off"}, ["off", "on"], [{}, {}], + [["one", "two"], ["one"]], ), ( "sensor", @@ -274,6 +277,7 @@ async def test_options( {"one": "30.0", "two": "20.0"}, ["unavailable", "50.0"], [{}, {}], + [["one"], ["one", "two"]], ), ), ) @@ -286,6 +290,7 @@ async def test_config_flow_preview( input_states: list[str], template_states: str, extra_attributes: list[dict[str, Any]], + listeners: list[list[str]], ) -> None: """Test the config flow preview.""" client = await hass_ws_client(hass) @@ -323,6 +328,12 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes[0], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[0]]), + "time": False, + }, "state": template_states[0], } @@ -336,6 +347,12 @@ async def test_config_flow_preview( "attributes": {"friendly_name": "My template"} | extra_attributes[0] | extra_attributes[1], + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners[1]]), + "time": False, + }, "state": template_states[1], } assert len(hass.states.async_all()) == 2 @@ -526,6 +543,7 @@ async def test_config_flow_preview_bad_state( "input_states", "template_state", "extra_attributes", + "listeners", ), [ ( @@ -537,6 +555,7 @@ async def test_config_flow_preview_bad_state( {"one": "on", "two": "off"}, "off", {}, + ["one", "two"], ), ( "sensor", @@ -547,6 +566,7 @@ async def test_config_flow_preview_bad_state( {"one": "30.0", "two": "20.0"}, "10.0", {}, + ["one", "two"], ), ], ) @@ -561,6 +581,7 @@ async def test_option_flow_preview( input_states: list[str], template_state: str, extra_attributes: dict[str, Any], + listeners: list[str], ) -> None: """Test the option flow preview.""" client = await hass_ws_client(hass) @@ -608,6 +629,12 @@ async def test_option_flow_preview( msg = await client.receive_json() assert msg["event"] == { "attributes": {"friendly_name": "My template"} | extra_attributes, + "listeners": { + "all": False, + "domains": [], + "entities": unordered([f"{template_type}.{_id}" for _id in listeners]), + "time": False, + }, "state": template_state, } assert len(hass.states.async_all()) == 3 From f41b0452442374c8cfb5f94ecde7dc81ead4ec90 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 09:55:25 +0200 Subject: [PATCH 146/640] Use shorthand attributes in Trend (#99695) --- CODEOWNERS | 2 ++ homeassistant/components/trend/binary_sensor.py | 16 +++------------- homeassistant/components/trend/manifest.json | 4 ++-- homeassistant/generated/integrations.json | 2 +- 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 79ff912f4b7603..58812a0baf23a2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1314,6 +1314,8 @@ build.json @home-assistant/supervisor /tests/components/trafikverket_weatherstation/ @endor-force @gjohansson-ST /homeassistant/components/transmission/ @engrbm87 @JPHutchins /tests/components/transmission/ @engrbm87 @JPHutchins +/homeassistant/components/trend/ @jpbede +/tests/components/trend/ @jpbede /homeassistant/components/tts/ @home-assistant/core @pvizeli /tests/components/tts/ @home-assistant/core @pvizeli /homeassistant/components/tuya/ @Tuya @zlinoliver @frenck diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 020f7903060821..815403e1e87af2 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -134,10 +134,10 @@ def __init__( """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) - self._name = friendly_name + self._attr_name = friendly_name + self._attr_device_class = device_class self._entity_id = entity_id self._attribute = attribute - self._device_class = device_class self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient @@ -145,27 +145,17 @@ def __init__( self._state = None self.samples = deque(maxlen=max_samples) - @property - def name(self): - """Return the name of the sensor.""" - return self._name - @property def is_on(self): """Return true if sensor is on.""" return self._state - @property - def device_class(self): - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self): """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, - ATTR_FRIENDLY_NAME: self._name, + ATTR_FRIENDLY_NAME: self._attr_name, ATTR_GRADIENT: self._gradient, ATTR_INVERT: self._invert, ATTR_MIN_GRADIENT: self._min_gradient, diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 77a0044ca1f93c..9bb5c4296c5f14 100644 --- a/homeassistant/components/trend/manifest.json +++ b/homeassistant/components/trend/manifest.json @@ -1,9 +1,9 @@ { "domain": "trend", "name": "Trend", - "codeowners": [], + "codeowners": ["@jpbede"], "documentation": "https://www.home-assistant.io/integrations/trend", - "iot_class": "local_push", + "iot_class": "calculated", "quality_scale": "internal", "requirements": ["numpy==1.23.2"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index acf12b4f05dc0b..39c7a82ce55aac 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5930,7 +5930,7 @@ "name": "Trend", "integration_type": "hub", "config_flow": false, - "iot_class": "local_push" + "iot_class": "calculated" }, "tuya": { "name": "Tuya", From 48f7924e9e762ccde7f86f9cce866a7b12d943bd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 10:03:35 +0200 Subject: [PATCH 147/640] Allow specifying a custom log function for template render (#99572) * Allow specifying a custom log function for template render * Bypass template cache when reporting errors + fix tests * Send errors as events * Fix logic for creating new TemplateEnvironment * Add strict mode back * Only send error events if report_errors is True * Force test of websocket_api only * Debug test * Run pytest with higher verbosity * Timeout after 1 minute, enable syslog output * Adjust timeout * Add debug logs * Fix unsafe call to WebSocketHandler._send_message * Remove debug code * Improve test coverage * Revert accidental change * Include severity in error events * Remove redundant information from error events --- .../components/websocket_api/commands.py | 32 +- homeassistant/helpers/event.py | 17 +- homeassistant/helpers/template.py | 113 ++++--- .../components/websocket_api/test_commands.py | 278 ++++++++++++++++-- tests/helpers/test_template.py | 18 +- 5 files changed, 374 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 84c7567a40eb53..7772bef66f9ae5 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -5,6 +5,7 @@ import datetime as dt from functools import lru_cache, partial import json +import logging from typing import Any, cast import voluptuous as vol @@ -505,6 +506,7 @@ def _cached_template(template_str: str, hass: HomeAssistant) -> template.Templat vol.Optional("variables"): dict, vol.Optional("timeout"): vol.Coerce(float), vol.Optional("strict", default=False): bool, + vol.Optional("report_errors", default=False): bool, } ) @decorators.async_response @@ -513,14 +515,32 @@ async def handle_render_template( ) -> None: """Handle render_template command.""" template_str = msg["template"] - template_obj = _cached_template(template_str, hass) + report_errors: bool = msg["report_errors"] + if report_errors: + template_obj = template.Template(template_str, hass) + else: + template_obj = _cached_template(template_str, hass) variables = msg.get("variables") timeout = msg.get("timeout") + @callback + def _error_listener(level: int, template_error: str) -> None: + connection.send_message( + messages.event_message( + msg["id"], + {"error": template_error, "level": logging.getLevelName(level)}, + ) + ) + + @callback + def _thread_safe_error_listener(level: int, template_error: str) -> None: + hass.loop.call_soon_threadsafe(_error_listener, level, template_error) + if timeout: try: + log_fn = _thread_safe_error_listener if report_errors else None timed_out = await template_obj.async_render_will_timeout( - timeout, variables, strict=msg["strict"] + timeout, variables, strict=msg["strict"], log_fn=log_fn ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) @@ -542,7 +562,11 @@ def _template_listener( track_template_result = updates.pop() result = track_template_result.result if isinstance(result, TemplateError): - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(result)) + if not report_errors: + return + connection.send_message( + messages.event_message(msg["id"], {"error": str(result)}) + ) return connection.send_message( @@ -552,12 +576,14 @@ def _template_listener( ) try: + log_fn = _error_listener if report_errors else None info = async_track_template_result( hass, [TrackTemplate(template_obj, variables)], _template_listener, raise_on_template_error=True, strict=msg["strict"], + log_fn=log_fn, ) except TemplateError as ex: connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b8831d38d863b0..22e274a7d0f797 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -915,7 +915,12 @@ def __repr__(self) -> str: """Return the representation.""" return f"" - def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> None: + def async_setup( + self, + raise_on_template_error: bool, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: """Activation of template tracking.""" block_render = False super_template = self._track_templates[0] if self._has_super_template else None @@ -925,7 +930,7 @@ def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> No template = super_template.template variables = super_template.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) # If the super template did not render to True, don't update other templates @@ -946,7 +951,7 @@ def async_setup(self, raise_on_template_error: bool, strict: bool = False) -> No template = track_template_.template variables = track_template_.variables self._info[template] = info = template.async_render_to_info( - variables, strict=strict + variables, strict=strict, log_fn=log_fn ) if info.exception: @@ -1233,6 +1238,7 @@ def async_track_template_result( action: TrackTemplateResultListener, raise_on_template_error: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, ) -> TrackTemplateResultInfo: """Add a listener that fires when the result of a template changes. @@ -1264,6 +1270,9 @@ def async_track_template_result( tracking. strict When set to True, raise on undefined variables. + log_fn + If not None, template error messages will logging by calling log_fn + instead of the normal logging facility. has_super_template When set to True, the first template will block rendering of other templates if it doesn't render as True. @@ -1274,7 +1283,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict) + tracker.async_setup(raise_on_template_error, strict=strict, log_fn=log_fn) return tracker diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index b5a6a45e97fa6e..9f280db6c98382 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -458,6 +458,7 @@ class Template: "_exc_info", "_limited", "_strict", + "_log_fn", "_hash_cache", "_renders", ) @@ -475,6 +476,7 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: self._exc_info: sys._OptExcInfo | None = None self._limited: bool | None = None self._strict: bool | None = None + self._log_fn: Callable[[int, str], None] | None = None self._hash_cache: int = hash(self.template) self._renders: int = 0 @@ -482,6 +484,11 @@ def __init__(self, template: str, hass: HomeAssistant | None = None) -> None: def _env(self) -> TemplateEnvironment: if self.hass is None: return _NO_HASS_ENV + # Bypass cache if a custom log function is specified + if self._log_fn is not None: + return TemplateEnvironment( + self.hass, self._limited, self._strict, self._log_fn + ) if self._limited: wanted_env = _ENVIRONMENT_LIMITED elif self._strict: @@ -491,9 +498,7 @@ def _env(self) -> TemplateEnvironment: ret: TemplateEnvironment | None = self.hass.data.get(wanted_env) if ret is None: ret = self.hass.data[wanted_env] = TemplateEnvironment( - self.hass, - self._limited, - self._strict, + self.hass, self._limited, self._strict, self._log_fn ) return ret @@ -537,6 +542,7 @@ def async_render( parse_result: bool = True, limited: bool = False, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> Any: """Render given template. @@ -553,7 +559,7 @@ def async_render( return self.template return self._parse_result(self.template) - compiled = self._compiled or self._ensure_compiled(limited, strict) + compiled = self._compiled or self._ensure_compiled(limited, strict, log_fn) if variables is not None: kwargs.update(variables) @@ -608,6 +614,7 @@ async def async_render_will_timeout( timeout: float, variables: TemplateVarsType = None, strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, **kwargs: Any, ) -> bool: """Check to see if rendering a template will timeout during render. @@ -628,7 +635,7 @@ async def async_render_will_timeout( if self.is_static: return False - compiled = self._compiled or self._ensure_compiled(strict=strict) + compiled = self._compiled or self._ensure_compiled(strict=strict, log_fn=log_fn) if variables is not None: kwargs.update(variables) @@ -664,7 +671,11 @@ def _render_template() -> None: @callback def async_render_to_info( - self, variables: TemplateVarsType = None, strict: bool = False, **kwargs: Any + self, + variables: TemplateVarsType = None, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, + **kwargs: Any, ) -> RenderInfo: """Render the template and collect an entity filter.""" self._renders += 1 @@ -680,7 +691,9 @@ def async_render_to_info( token = _render_info.set(render_info) try: - render_info._result = self.async_render(variables, strict=strict, **kwargs) + render_info._result = self.async_render( + variables, strict=strict, log_fn=log_fn, **kwargs + ) except TemplateError as ex: render_info.exception = ex finally: @@ -743,7 +756,10 @@ def async_render_with_possible_json_value( return value if error_value is _SENTINEL else error_value def _ensure_compiled( - self, limited: bool = False, strict: bool = False + self, + limited: bool = False, + strict: bool = False, + log_fn: Callable[[int, str], None] | None = None, ) -> jinja2.Template: """Bind a template to a specific hass instance.""" self.ensure_valid() @@ -756,10 +772,14 @@ def _ensure_compiled( self._strict is None or self._strict == strict ), "can't change between strict and non strict template" assert not (strict and limited), "can't combine strict and limited template" + assert ( + self._log_fn is None or self._log_fn == log_fn + ), "can't change custom log function" assert self._compiled_code is not None, "template code was not compiled" self._limited = limited self._strict = strict + self._log_fn = log_fn env = self._env self._compiled = jinja2.Template.from_code( @@ -2178,45 +2198,56 @@ def _render_with_context( return template.render(**kwargs) -class LoggingUndefined(jinja2.Undefined): +def make_logging_undefined( + strict: bool | None, log_fn: Callable[[int, str], None] | None +) -> type[jinja2.Undefined]: """Log on undefined variables.""" - def _log_message(self) -> None: + if strict: + return jinja2.StrictUndefined + + def _log_with_logger(level: int, msg: str) -> None: template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.warning( - "Template variable warning: %s when %s '%s'", - self._undefined_message, + _LOGGER.log( + level, + "Template variable %s: %s when %s '%s'", + logging.getLevelName(level).lower(), + msg, action, template, ) - def _fail_with_undefined_error(self, *args, **kwargs): - try: - return super()._fail_with_undefined_error(*args, **kwargs) - except self._undefined_exception as ex: - template, action = template_cv.get() or ("", "rendering or compiling") - _LOGGER.error( - "Template variable error: %s when %s '%s'", - self._undefined_message, - action, - template, - ) - raise ex + _log_fn = log_fn or _log_with_logger - def __str__(self) -> str: - """Log undefined __str___.""" - self._log_message() - return super().__str__() + class LoggingUndefined(jinja2.Undefined): + """Log on undefined variables.""" - def __iter__(self): - """Log undefined __iter___.""" - self._log_message() - return super().__iter__() + def _log_message(self) -> None: + _log_fn(logging.WARNING, self._undefined_message) - def __bool__(self) -> bool: - """Log undefined __bool___.""" - self._log_message() - return super().__bool__() + def _fail_with_undefined_error(self, *args, **kwargs): + try: + return super()._fail_with_undefined_error(*args, **kwargs) + except self._undefined_exception as ex: + _log_fn(logging.ERROR, self._undefined_message) + raise ex + + def __str__(self) -> str: + """Log undefined __str___.""" + self._log_message() + return super().__str__() + + def __iter__(self): + """Log undefined __iter___.""" + self._log_message() + return super().__iter__() + + def __bool__(self) -> bool: + """Log undefined __bool___.""" + self._log_message() + return super().__bool__() + + return LoggingUndefined async def async_load_custom_templates(hass: HomeAssistant) -> None: @@ -2281,14 +2312,10 @@ def __init__( hass: HomeAssistant | None, limited: bool | None = False, strict: bool | None = False, + log_fn: Callable[[int, str], None] | None = None, ) -> None: """Initialise template environment.""" - undefined: type[LoggingUndefined] | type[jinja2.StrictUndefined] - if not strict: - undefined = LoggingUndefined - else: - undefined = jinja2.StrictUndefined - super().__init__(undefined=undefined) + super().__init__(undefined=make_logging_undefined(strict, log_fn)) self.hass = hass self.template_cache: weakref.WeakValueDictionary[ str | jinja2.nodes.Template, CodeType | str | None diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 73baa968ab6c17..96e79a81716916 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -2,6 +2,7 @@ import asyncio from copy import deepcopy import datetime +import logging from unittest.mock import ANY, AsyncMock, Mock, patch import pytest @@ -33,7 +34,11 @@ async_mock_service, mock_platform, ) -from tests.typing import ClientSessionGenerator, WebSocketGenerator +from tests.typing import ( + ClientSessionGenerator, + MockHAClientWebSocket, + WebSocketGenerator, +) STATE_KEY_SHORT_NAMES = { "entity_id": "e", @@ -1225,46 +1230,187 @@ async def test_render_template_manual_entity_ids_no_longer_needed( } +EMPTY_LISTENERS = {"all": False, "entities": [], "domains": [], "time": False} + +ERR_MSG = {"type": "result", "success": False} + +VARIABLE_ERROR_UNDEFINED_FUNC = { + "error": "'my_unknown_func' is undefined", + "level": "ERROR", +} +TEMPLATE_ERROR_UNDEFINED_FUNC = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_func' is undefined", +} + +VARIABLE_WARNING_UNDEFINED_VAR = { + "error": "'my_unknown_var' is undefined", + "level": "WARNING", +} +TEMPLATE_ERROR_UNDEFINED_VAR = { + "code": "template_error", + "message": "UndefinedError: 'my_unknown_var' is undefined", +} + +TEMPLATE_ERROR_UNDEFINED_FILTER = { + "code": "template_error", + "message": "TemplateAssertionError: No filter named 'unknown_filter'.", +} + + @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template, "strict": True} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @pytest.mark.parametrize( - "template", + ("template", "expected_events"), [ - "{{ my_unknown_func() + 1 }}", - "{{ my_unknown_var }}", - "{{ my_unknown_var + 1 }}", - "{{ now() | unknown_filter }}", + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, + ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + { + "type": "event", + "event": {"result": "", "listeners": EMPTY_LISTENERS}, + }, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), ], ) async def test_render_template_with_timeout_and_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture, template + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], ) -> None: """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "report_errors": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text + assert "TemplateError" not in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}], + ), + ( + "{{ my_unknown_var }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ my_unknown_var + 1 }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + ), + ( + "{{ now() | unknown_filter }}", + [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout.""" + caplog.set_level(logging.INFO) await websocket_client.send_json( { "id": 5, @@ -1275,13 +1421,14 @@ async def test_render_template_with_timeout_and_error( } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text @@ -1299,13 +1446,19 @@ async def test_render_template_error_in_template_code( assert not msg["success"] assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text async def test_render_template_with_delayed_error( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: - """Test a template with an error that only happens after a state change.""" + """Test a template with an error that only happens after a state change. + + In this test report_errors is enabled. + """ + caplog.set_level(logging.INFO) hass.states.async_set("sensor.test", "on") await hass.async_block_till_done() @@ -1318,12 +1471,16 @@ async def test_render_template_with_delayed_error( """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": template_str} + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": True, + } ) await hass.async_block_till_done() msg = await websocket_client.receive_json() - assert msg["id"] == 5 assert msg["type"] == const.TYPE_RESULT assert msg["success"] @@ -1347,13 +1504,74 @@ async def test_render_template_with_delayed_error( msg = await websocket_client.receive_json() assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + assert msg["type"] == "event" + event = msg["event"] + assert event["error"] == "'None' has no attribute 'state'" + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == {"error": "UndefinedError: 'explode' is undefined"} + assert "Template variable error" not in caplog.text + assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +async def test_render_template_with_delayed_error_2( + hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture +) -> None: + """Test a template with an error that only happens after a state change. + + In this test report_errors is disabled. + """ + hass.states.async_set("sensor.test", "on") + await hass.async_block_till_done() + + template_str = """ +{% if states.sensor.test.state %} + on +{% else %} + {{ explode + 1 }} +{% endif %} + """ + + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template_str, + "report_errors": False, + } + ) + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == const.TYPE_RESULT + assert msg["success"] + + hass.states.async_remove("sensor.test") + await hass.async_block_till_done() + + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + assert msg["type"] == "event" + event = msg["event"] + assert event == { + "result": "on", + "listeners": { + "all": False, + "domains": [], + "entities": ["sensor.test"], + "time": False, + }, + } + + assert "Template variable warning" in caplog.text + + async def test_render_template_with_timeout( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index d14496d321e337..58e0c730165b50 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -4466,15 +4466,25 @@ async def test_parse_result(hass: HomeAssistant) -> None: assert template.Template(tpl, hass).async_render() == result -async def test_undefined_variable( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture +@pytest.mark.parametrize( + "template_string", + [ + "{{ no_such_variable }}", + "{{ no_such_variable and True }}", + "{{ no_such_variable | join(', ') }}", + ], +) +async def test_undefined_symbol_warnings( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + template_string: str, ) -> None: """Test a warning is logged on undefined variables.""" - tpl = template.Template("{{ no_such_variable }}", hass) + tpl = template.Template(template_string, hass) assert tpl.async_render() == "" assert ( "Template variable warning: 'no_such_variable' is undefined when rendering " - "'{{ no_such_variable }}'" in caplog.text + f"'{template_string}'" in caplog.text ) From 687e69f7c33e78726bef3178d3a529adc446cea0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 10:35:04 +0200 Subject: [PATCH 148/640] Fix unit conversion for gas cost sensor (#99708) --- homeassistant/components/energy/sensor.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index ae92ee2de58997..e9760a96aa4e1f 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -377,11 +377,10 @@ def _update_cost(self) -> None: if energy_price_unit is None: converted_energy_price = energy_price else: - if self._adapter.source_type == "grid": - converter: Callable[ - [float, str, str], float - ] = unit_conversion.EnergyConverter.convert - elif self._adapter.source_type in ("gas", "water"): + converter: Callable[[float, str, str], float] + if energy_unit in VALID_ENERGY_UNITS: + converter = unit_conversion.EnergyConverter.convert + else: converter = unit_conversion.VolumeConverter.convert converted_energy_price = converter( From 00ada69e0b7b4b65fca9372aa3d693830fd63860 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Sep 2023 10:40:05 +0200 Subject: [PATCH 149/640] Update frontend to 20230906.0 (#99715) --- 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 627b36a59b854a..9e0bd3e5de9f6b 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==20230905.0"] + "requirements": ["home-assistant-frontend==20230906.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c4492c90e9cd7d..810f6d093bf9c4 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index c900fb37c381ca..04eddfd1de3d31 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f3d8fc5d322660..75dd6db70f5703 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230905.0 +home-assistant-frontend==20230906.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 71afa0ff433fcb7a6173fe1b736f34cf81e6ad7b Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Wed, 6 Sep 2023 10:46:52 +0200 Subject: [PATCH 150/640] Yellow LED controls: rename LEDs (#99710) - reorder, to reflect placement on board, left to right (yellow, green, red) --- homeassistant/components/homeassistant_yellow/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index e5250f163cec4a..68e87c06024458 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -27,9 +27,9 @@ "hardware_settings": { "title": "Configure hardware settings", "data": { - "disk_led": "Disk LED", - "heartbeat_led": "Heartbeat LED", - "power_led": "Power LED" + "heartbeat_led": "Yellow: system health LED", + "disk_led": "Green: activity LED", + "power_led": "Red: power LED" } }, "install_addon": { From 034fabe188c183e93f689c2a88790b93125ddd78 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 04:04:49 -0500 Subject: [PATCH 151/640] Use loop time to set context (#99701) * Use loop time to set context loop time is faster than utcnow, and since its only used internally it can be switched without a breaking change * fix mocking --- homeassistant/helpers/entity.py | 11 ++++++----- tests/helpers/test_service.py | 7 ++++--- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index e946c41d3b8be2..7bd510b6fa13d2 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -5,7 +5,7 @@ import asyncio from collections.abc import Coroutine, Iterable, Mapping, MutableMapping from dataclasses import dataclass -from datetime import datetime, timedelta +from datetime import timedelta from enum import Enum, auto import functools as ft import logging @@ -41,7 +41,7 @@ NoEntitySpecifiedError, ) from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util, ensure_unique_string, slugify +from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er from .device_registry import DeviceInfo, EventDeviceRegistryUpdatedData @@ -272,7 +272,7 @@ class Entity(ABC): # Context _context: Context | None = None - _context_set: datetime | None = None + _context_set: float | None = None # If entity is added to an entity platform _platform_state = EntityPlatformState.NOT_ADDED @@ -660,7 +660,7 @@ def enabled(self) -> bool: def async_set_context(self, context: Context) -> None: """Set the context the entity currently operates under.""" self._context = context - self._context_set = dt_util.utcnow() + self._context_set = self.hass.loop.time() async def async_update_ha_state(self, force_refresh: bool = False) -> None: """Update Home Assistant with current state of entity. @@ -847,7 +847,8 @@ def _async_write_ha_state(self) -> None: if ( self._context_set is not None - and dt_util.utcnow() - self._context_set > self.context_recent_time + and hass.loop.time() - self._context_set + > self.context_recent_time.total_seconds() ): self._context = None self._context_set = None diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 803a57e12edac9..03a8b5e11b2d8c 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1,5 +1,4 @@ """Test service helpers.""" -from collections import OrderedDict from collections.abc import Iterable from copy import deepcopy from typing import Any @@ -54,7 +53,7 @@ def mock_handle_entity_call(): @pytest.fixture -def mock_entities(hass): +def mock_entities(hass: HomeAssistant) -> dict[str, MockEntity]: """Return mock entities in an ordered dict.""" kitchen = MockEntity( entity_id="light.kitchen", @@ -80,11 +79,13 @@ def mock_entities(hass): should_poll=False, supported_features=(SUPPORT_B | SUPPORT_C), ) - entities = OrderedDict() + entities = {} entities[kitchen.entity_id] = kitchen entities[living_room.entity_id] = living_room entities[bedroom.entity_id] = bedroom entities[bathroom.entity_id] = bathroom + for entity in entities.values(): + entity.hass = hass return entities From 274507b5c9df0fca216d2e2c54c8f2ad5abd677a Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 11:35:57 +0200 Subject: [PATCH 152/640] Fix pylint plugin test DeprecationWarning (#99711) --- tests/pylint/conftest.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index e8748434350a9e..4a53f686c5ae16 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -1,6 +1,7 @@ """Configuration for pylint tests.""" -from importlib.machinery import SourceFileLoader +from importlib.util import module_from_spec, spec_from_file_location from pathlib import Path +import sys from types import ModuleType from pylint.checkers import BaseChecker @@ -13,11 +14,17 @@ @pytest.fixture(name="hass_enforce_type_hints", scope="session") def hass_enforce_type_hints_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( - "hass_enforce_type_hints", + module_name = "hass_enforce_type_hints" + spec = spec_from_file_location( + module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), ) - return loader.load_module(None) + assert spec and spec.loader + + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module @pytest.fixture(name="linter") @@ -37,11 +44,16 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - loader = SourceFileLoader( - "hass_imports", - str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")), + module_name = "hass_imports" + spec = spec_from_file_location( + module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")) ) - return loader.load_module(None) + assert spec and spec.loader + + module = module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + return module @pytest.fixture(name="imports_checker") From b815ea1332667e10c5af3c4387bcfa41961d590f Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 11:54:18 +0200 Subject: [PATCH 153/640] Revert "Remove imap_email_content integration" (#99713) --- .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, 1193 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/imap_email_content/__init__.py create mode 100644 homeassistant/components/imap_email_content/const.py create mode 100644 homeassistant/components/imap_email_content/manifest.json create mode 100644 homeassistant/components/imap_email_content/repairs.py create mode 100644 homeassistant/components/imap_email_content/sensor.py create mode 100644 homeassistant/components/imap_email_content/strings.json create mode 100644 tests/components/imap_email_content/__init__.py create mode 100644 tests/components/imap_email_content/test_repairs.py create mode 100644 tests/components/imap_email_content/test_sensor.py diff --git a/.coveragerc b/.coveragerc index f2231ea31c2eeb..d28878d8861fd4 100644 --- a/.coveragerc +++ b/.coveragerc @@ -547,6 +547,7 @@ 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 70594d5fd7cd5a..4c4a2e2a35c5ef 100644 --- a/homeassistant/components/imap/config_flow.py +++ b/homeassistant/components/imap/config_flow.py @@ -10,7 +10,13 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_PASSWORD, CONF_PORT, CONF_USERNAME, CONF_VERIFY_SSL +from homeassistant.const import ( + CONF_NAME, + 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 @@ -126,6 +132,28 @@ 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 new file mode 100644 index 00000000000000..f2041b947df6d6 --- /dev/null +++ b/homeassistant/components/imap_email_content/__init__.py @@ -0,0 +1,17 @@ +"""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 new file mode 100644 index 00000000000000..5f1c653030e568 --- /dev/null +++ b/homeassistant/components/imap_email_content/const.py @@ -0,0 +1,13 @@ +"""Constants for the imap email content integration.""" + +DOMAIN = "imap_email_content" + +CONF_SERVER = "server" +CONF_SENDERS = "senders" +CONF_FOLDER = "folder" + +ATTR_FROM = "from" +ATTR_BODY = "body" +ATTR_SUBJECT = "subject" + +DEFAULT_PORT = 993 diff --git a/homeassistant/components/imap_email_content/manifest.json b/homeassistant/components/imap_email_content/manifest.json new file mode 100644 index 00000000000000..b7d0589b83f201 --- /dev/null +++ b/homeassistant/components/imap_email_content/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "imap_email_content", + "name": "IMAP Email Content", + "codeowners": [], + "dependencies": ["imap"], + "documentation": "https://www.home-assistant.io/integrations/imap_email_content", + "iot_class": "cloud_push" +} diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py new file mode 100644 index 00000000000000..f19b0499040b0a --- /dev/null +++ b/homeassistant/components/imap_email_content/repairs.py @@ -0,0 +1,173 @@ +"""Repair flow for imap email content integration.""" + +from typing import Any + +import voluptuous as vol +import yaml + +from homeassistant import data_entry_flow +from homeassistant.components.imap import DOMAIN as IMAP_DOMAIN +from homeassistant.components.repairs import RepairsFlow +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VALUE_TEMPLATE, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import issue_registry as ir +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_FOLDER, CONF_SENDERS, CONF_SERVER, DOMAIN + + +async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: + """Register an issue and suggest new config.""" + + name: str = config.get(CONF_NAME) or config[CONF_USERNAME] + + issue_id = ( + f"{name}_{config[CONF_USERNAME]}_{config[CONF_SERVER]}_{config[CONF_FOLDER]}" + ) + + if CONF_VALUE_TEMPLATE in config: + template: str = config[CONF_VALUE_TEMPLATE].template + template = template.replace("subject", 'trigger.event.data["subject"]') + template = template.replace("from", 'trigger.event.data["sender"]') + template = template.replace("date", 'trigger.event.data["date"]') + template = template.replace("body", 'trigger.event.data["text"]') + else: + template = '{{ trigger.event.data["subject"] }}' + + template_sensor_config: ConfigType = { + "template": [ + { + "trigger": [ + { + "id": "custom_event", + "platform": "event", + "event_type": "imap_content", + "event_data": {"sender": config[CONF_SENDERS][0]}, + } + ], + "sensor": [ + { + "state": template, + "name": name, + } + ], + } + ] + } + + data = { + CONF_SERVER: config[CONF_SERVER], + CONF_PORT: config[CONF_PORT], + CONF_USERNAME: config[CONF_USERNAME], + CONF_PASSWORD: config[CONF_PASSWORD], + CONF_FOLDER: config[CONF_FOLDER], + } + data[CONF_VALUE_TEMPLATE] = template + data[CONF_NAME] = name + placeholders = {"yaml_example": yaml.dump(template_sensor_config)} + placeholders.update(data) + + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=True, + severity=ir.IssueSeverity.WARNING, + translation_key="migration", + translation_placeholders=placeholders, + data=data, + ) + + +class DeprecationRepairFlow(RepairsFlow): + """Handler for an issue fixing flow.""" + + def __init__(self, issue_id: str, config: ConfigType) -> None: + """Create flow.""" + self._name: str = config[CONF_NAME] + self._config: dict[str, Any] = config + self._issue_id = issue_id + super().__init__() + + async def async_step_init( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the first step of a fix flow.""" + return await self.async_step_start() + + @callback + def _async_get_placeholders(self) -> dict[str, str] | None: + issue_registry = ir.async_get(self.hass) + description_placeholders = None + if issue := issue_registry.async_get_issue(self.handler, self.issue_id): + description_placeholders = issue.translation_placeholders + + return description_placeholders + + async def async_step_start( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Wait for the user to start the config migration.""" + placeholders = self._async_get_placeholders() + if user_input is None: + return self.async_show_form( + step_id="start", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, str] | None = None + ) -> data_entry_flow.FlowResult: + """Handle the confirm step of a fix flow.""" + placeholders = self._async_get_placeholders() + if user_input is not None: + user_input[CONF_NAME] = self._name + result = await self.hass.config_entries.flow.async_init( + IMAP_DOMAIN, context={"source": SOURCE_IMPORT}, data=self._config + ) + if result["type"] == FlowResultType.ABORT: + ir.async_delete_issue(self.hass, DOMAIN, self._issue_id) + ir.async_create_issue( + self.hass, + DOMAIN, + self._issue_id, + breaks_in_ha_version="2023.10.0", + is_fixable=False, + severity=ir.IssueSeverity.WARNING, + translation_key="deprecation", + translation_placeholders=placeholders, + data=self._config, + learn_more_url="https://www.home-assistant.io/integrations/imap/#using-events", + ) + return self.async_abort(reason=result["reason"]) + return self.async_create_entry( + title="", + data={}, + ) + + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema({}), + description_placeholders=placeholders, + ) + + +async def async_create_fix_flow( + hass: HomeAssistant, + issue_id: str, + data: dict[str, str | int | float | None], +) -> RepairsFlow: + """Create flow.""" + return DeprecationRepairFlow(issue_id, data) diff --git a/homeassistant/components/imap_email_content/sensor.py b/homeassistant/components/imap_email_content/sensor.py new file mode 100644 index 00000000000000..1df207e29687a8 --- /dev/null +++ b/homeassistant/components/imap_email_content/sensor.py @@ -0,0 +1,302 @@ +"""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 new file mode 100644 index 00000000000000..b7b987b1212cb1 --- /dev/null +++ b/homeassistant/components/imap_email_content/strings.json @@ -0,0 +1,27 @@ +{ + "issues": { + "deprecation": { + "title": "The IMAP email content integration is deprecated", + "description": "The IMAP email content integration is deprecated. Your IMAP server configuration was already migrated to 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 39c7a82ce55aac..379dd11267224f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2581,6 +2581,12 @@ "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 d36cffbce0682d..efb505cda774db 100644 --- a/tests/components/imap/test_config_flow.py +++ b/tests/components/imap/test_config_flow.py @@ -469,6 +469,73 @@ 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 new file mode 100644 index 00000000000000..2c7e569236655c --- /dev/null +++ b/tests/components/imap_email_content/__init__.py @@ -0,0 +1 @@ +"""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 new file mode 100644 index 00000000000000..6323dcde3776b3 --- /dev/null +++ b/tests/components/imap_email_content/test_repairs.py @@ -0,0 +1,296 @@ +"""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 new file mode 100644 index 00000000000000..3e8a6c1e28299e --- /dev/null +++ b/tests/components/imap_email_content/test_sensor.py @@ -0,0 +1,253 @@ +"""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 397952ceeaa6df91d989e4ebf6a523f023eeea09 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 12:45:46 +0200 Subject: [PATCH 154/640] Postpone Imap_email_content removal (#99721) --- homeassistant/components/imap_email_content/repairs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/imap_email_content/repairs.py b/homeassistant/components/imap_email_content/repairs.py index f19b0499040b0a..8fe05f80c0805e 100644 --- a/homeassistant/components/imap_email_content/repairs.py +++ b/homeassistant/components/imap_email_content/repairs.py @@ -79,7 +79,7 @@ async def async_process_issue(hass: HomeAssistant, config: ConfigType) -> None: hass, DOMAIN, issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=True, severity=ir.IssueSeverity.WARNING, translation_key="migration", @@ -143,7 +143,7 @@ async def async_step_confirm( self.hass, DOMAIN, self._issue_id, - breaks_in_ha_version="2023.10.0", + breaks_in_ha_version="2023.11.0", is_fixable=False, severity=ir.IssueSeverity.WARNING, translation_key="deprecation", From 0037385336580d1fa4f09d9676c3279083f36837 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 6 Sep 2023 14:46:24 +0200 Subject: [PATCH 155/640] Reolink onvif not supported fix (#99714) * only subscibe to ONVIF if supported * Catch NotSupportedError when ONVIF is not supported * fix styling --- homeassistant/components/reolink/host.py | 47 +++++++++++++++++------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a679cb34f4bc22..a43dbce9a7c523 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -10,7 +10,7 @@ from aiohttp.web import Request from reolink_aio.api import Host from reolink_aio.enums import SubType -from reolink_aio.exceptions import ReolinkError, SubscriptionError +from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError from homeassistant.components import webhook from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_USERNAME @@ -61,6 +61,7 @@ def __init__( ) self.webhook_id: str | None = None + self._onvif_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -96,6 +97,8 @@ async def async_init(self) -> None: f"'{self._api.user_level}', only admin users can change camera settings" ) + self._onvif_supported = self._api.supported(None, "ONVIF") + enable_rtsp = None enable_onvif = None enable_rtmp = None @@ -106,7 +109,7 @@ async def async_init(self) -> None: ) enable_rtsp = True - if not self._api.onvif_enabled: + if not self._api.onvif_enabled and self._onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -154,21 +157,34 @@ async def async_init(self) -> None: self._unique_id = format_mac(self._api.mac_address) - await self.subscribe() - - if self._api.supported(None, "initial_ONVIF_state"): - _LOGGER.debug( - "Waiting for initial ONVIF state on webhook '%s'", self._webhook_url - ) - else: + if self._onvif_supported: + try: + await self.subscribe() + except NotSupportedError: + self._onvif_supported = False + self.unregister_webhook() + await self._api.unsubscribe() + else: + if self._api.supported(None, "initial_ONVIF_state"): + _LOGGER.debug( + "Waiting for initial ONVIF state on webhook '%s'", + self._webhook_url, + ) + else: + _LOGGER.debug( + "Camera model %s most likely does not push its initial state" + " upon ONVIF subscription, do not check", + self._api.model, + ) + self._cancel_onvif_check = async_call_later( + self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif + ) + if not self._onvif_supported: _LOGGER.debug( - "Camera model %s most likely does not push its initial state" - " upon ONVIF subscription, do not check", + "Camera model %s does not support ONVIF, using fast polling instead", self._api.model, ) - self._cancel_onvif_check = async_call_later( - self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif - ) + await self._async_poll_all_motion() if self._api.sw_version_update_required: ir.async_create_issue( @@ -365,6 +381,9 @@ async def subscribe(self) -> None: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" + if not self._onvif_supported: + return + try: await self._renew(SubType.push) if self._long_poll_task is not None: From 9700888df168ad017d544982198e3da7b79bb9e5 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Sep 2023 15:00:26 +0200 Subject: [PATCH 156/640] Update frontend to 20230906.1 (#99733) --- 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 9e0bd3e5de9f6b..50c557eae89636 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==20230906.0"] + "requirements": ["home-assistant-frontend==20230906.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 810f6d093bf9c4..e0d75c1ec20d34 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 04eddfd1de3d31..2e4499d5b3fece 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -996,7 +996,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 75dd6db70f5703..7a1b03834318a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -779,7 +779,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.0 +home-assistant-frontend==20230906.1 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From bb765449eb32802c4db4396d8c79fec26bc2e418 Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 6 Sep 2023 09:03:54 -0400 Subject: [PATCH 157/640] Add binary_sensor to Schlage (#99637) * Add binary_sensor to Schlage * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/schlage/__init__.py | 7 +- .../components/schlage/binary_sensor.py | 92 +++++++++++++++++++ homeassistant/components/schlage/strings.json | 5 + tests/components/schlage/conftest.py | 1 + .../components/schlage/test_binary_sensor.py | 53 +++++++++++ 5 files changed, 157 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/schlage/binary_sensor.py create mode 100644 tests/components/schlage/test_binary_sensor.py diff --git a/homeassistant/components/schlage/__init__.py b/homeassistant/components/schlage/__init__.py index cf95e190e88d6c..feaa95864d56ee 100644 --- a/homeassistant/components/schlage/__init__.py +++ b/homeassistant/components/schlage/__init__.py @@ -11,7 +11,12 @@ from .const import DOMAIN, LOGGER from .coordinator import SchlageDataUpdateCoordinator -PLATFORMS: list[Platform] = [Platform.LOCK, Platform.SENSOR, Platform.SWITCH] +PLATFORMS: list[Platform] = [ + Platform.BINARY_SENSOR, + Platform.LOCK, + Platform.SENSOR, + Platform.SWITCH, +] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/schlage/binary_sensor.py b/homeassistant/components/schlage/binary_sensor.py new file mode 100644 index 00000000000000..749a961a53b922 --- /dev/null +++ b/homeassistant/components/schlage/binary_sensor.py @@ -0,0 +1,92 @@ +"""Platform for Schlage binary_sensor integration.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +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 +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import LockData, SchlageDataUpdateCoordinator +from .entity import SchlageEntity + + +@dataclass +class SchlageBinarySensorEntityDescriptionMixin: + """Mixin for required keys.""" + + # NOTE: This has to be a mixin because these are required keys. + # BinarySensorEntityDescription has attributes with default values, + # which means we can't inherit from it because you haven't have + # non-default arguments follow default arguments in an initializer. + + value_fn: Callable[[LockData], bool] + + +@dataclass +class SchlageBinarySensorEntityDescription( + BinarySensorEntityDescription, SchlageBinarySensorEntityDescriptionMixin +): + """Entity description for a Schlage binary_sensor.""" + + +_DESCRIPTIONS: tuple[SchlageBinarySensorEntityDescription] = ( + SchlageBinarySensorEntityDescription( + key="keypad_disabled", + translation_key="keypad_disabled", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.lock.keypad_disabled(data.logs), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up binary_sensors based on a config entry.""" + coordinator: SchlageDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + entities = [] + for device_id in coordinator.data.locks: + for description in _DESCRIPTIONS: + entities.append( + SchlageBinarySensor( + coordinator=coordinator, + description=description, + device_id=device_id, + ) + ) + async_add_entities(entities) + + +class SchlageBinarySensor(SchlageEntity, BinarySensorEntity): + """Schlage binary_sensor entity.""" + + entity_description: SchlageBinarySensorEntityDescription + + def __init__( + self, + coordinator: SchlageDataUpdateCoordinator, + description: SchlageBinarySensorEntityDescription, + device_id: str, + ) -> None: + """Initialize a SchlageBinarySensor.""" + super().__init__(coordinator, device_id) + self.entity_description = description + self._attr_unique_id = f"{device_id}_{self.entity_description.key}" + + @property + def is_on(self) -> bool | None: + """Return true if the binary_sensor is on.""" + return self.entity_description.value_fn(self._lock_data) diff --git a/homeassistant/components/schlage/strings.json b/homeassistant/components/schlage/strings.json index f3612bb96b8e49..076ed97e298d89 100644 --- a/homeassistant/components/schlage/strings.json +++ b/homeassistant/components/schlage/strings.json @@ -17,6 +17,11 @@ } }, "entity": { + "binary_sensor": { + "keypad_disabled": { + "name": "Keypad disabled" + } + }, "switch": { "beeper": { "name": "Keypress Beep" diff --git a/tests/components/schlage/conftest.py b/tests/components/schlage/conftest.py index 0078e6a5553d63..7b610a6b4da850 100644 --- a/tests/components/schlage/conftest.py +++ b/tests/components/schlage/conftest.py @@ -85,4 +85,5 @@ def mock_lock(): ) mock_lock.logs.return_value = [] mock_lock.last_changed_by.return_value = "thumbturn" + mock_lock.keypad_disabled.return_value = False return mock_lock diff --git a/tests/components/schlage/test_binary_sensor.py b/tests/components/schlage/test_binary_sensor.py new file mode 100644 index 00000000000000..4673f263c8cf05 --- /dev/null +++ b/tests/components/schlage/test_binary_sensor.py @@ -0,0 +1,53 @@ +"""Test Schlage binary_sensor.""" + +from datetime import timedelta +from unittest.mock import Mock + +from pyschlage.exceptions import UnknownError + +from homeassistant.components.binary_sensor import BinarySensorDeviceClass +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from tests.common import async_fire_time_changed + + +async def test_keypad_disabled_binary_sensor( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + + # Make the coordinator refresh data. + async_fire_time_changed(hass, utcnow() + timedelta(seconds=31)) + await hass.async_block_till_done() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) + + +async def test_keypad_disabled_binary_sensor_use_previous_logs_on_failure( + hass: HomeAssistant, mock_lock: Mock, mock_added_config_entry: ConfigEntry +) -> None: + """Test the keypad_disabled binary_sensor.""" + mock_lock.keypad_disabled.reset_mock() + mock_lock.keypad_disabled.return_value = True + mock_lock.logs.reset_mock() + 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() + + keypad = hass.states.get("binary_sensor.vault_door_keypad_disabled") + assert keypad is not None + assert keypad.state == "on" + assert keypad.attributes["device_class"] == BinarySensorDeviceClass.PROBLEM + + mock_lock.keypad_disabled.assert_called_once_with([]) From 97710dc5b73ef053ee78f748c15bb0aebcc11ee5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 15:59:03 +0200 Subject: [PATCH 158/640] Correct state attributes in group helper preview (#99723) --- homeassistant/components/group/config_flow.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/group/config_flow.py b/homeassistant/components/group/config_flow.py index 9eb973b960970b..93160b0db5bb06 100644 --- a/homeassistant/components/group/config_flow.py +++ b/homeassistant/components/group/config_flow.py @@ -361,6 +361,7 @@ def ws_start_preview( msg: dict[str, Any], ) -> None: """Generate a preview.""" + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) group_type = flow_status["step_id"] @@ -370,12 +371,17 @@ def ws_start_preview( name = validated["name"] else: flow_status = hass.config_entries.options.async_get(msg["flow_id"]) - config_entry = hass.config_entries.async_get_entry(flow_status["handler"]) + config_entry_id = flow_status["handler"] + config_entry = hass.config_entries.async_get_entry(config_entry_id) if not config_entry: raise HomeAssistantError group_type = config_entry.options["group_type"] name = config_entry.options["name"] validated = PREVIEW_OPTIONS_SCHEMA[group_type](msg["user_input"]) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + if entries: + entity_registry_entry = entries[0] @callback def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: @@ -388,6 +394,7 @@ def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: preview_entity = CREATE_PREVIEW_ENTITY[group_type](name, validated) preview_entity.hass = hass + preview_entity.registry_entry = entity_registry_entry connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( From c376447ccdd1068e828b146b139854940be8b09a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 15:59:30 +0200 Subject: [PATCH 159/640] Don't allow changing device class in template binary sensor options (#99720) --- homeassistant/components/template/config_flow.py | 8 ++++---- homeassistant/components/template/strings.json | 1 - 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 093cbf140983fb..15be2c52d9114c 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -40,11 +40,11 @@ NONE_SENTINEL = "none" -def generate_schema(domain: str) -> dict[vol.Marker, Any]: +def generate_schema(domain: str, flow_type: str) -> dict[vol.Marker, Any]: """Generate schema.""" schema: dict[vol.Marker, Any] = {} - if domain == Platform.BINARY_SENSOR: + if domain == Platform.BINARY_SENSOR and flow_type == "config": schema = { vol.Optional(CONF_DEVICE_CLASS): selector.SelectSelector( selector.SelectSelectorConfig( @@ -124,7 +124,7 @@ def options_schema(domain: str) -> vol.Schema: """Generate options schema.""" return vol.Schema( {vol.Required(CONF_STATE): selector.TemplateSelector()} - | generate_schema(domain), + | generate_schema(domain, "option"), ) @@ -135,7 +135,7 @@ def config_schema(domain: str) -> vol.Schema: vol.Required(CONF_NAME): selector.TextSelector(), vol.Required(CONF_STATE): selector.TemplateSelector(), } - | generate_schema(domain), + | generate_schema(domain, "config"), ) diff --git a/homeassistant/components/template/strings.json b/homeassistant/components/template/strings.json index 7e5e56a26d6248..a0ee31126cd75a 100644 --- a/homeassistant/components/template/strings.json +++ b/homeassistant/components/template/strings.json @@ -33,7 +33,6 @@ "step": { "binary_sensor": { "data": { - "device_class": "[%key:component::template::config::step::sensor::data::device_class%]", "state": "[%key:component::template::config::step::sensor::data::state%]" }, "title": "[%key:component::template::config::step::binary_sensor::title%]" From e1ea53e72fbcedfbb31af825a525610bc0464853 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 16:06:33 +0200 Subject: [PATCH 160/640] Correct state attributes in template helper preview (#99722) --- homeassistant/components/template/config_flow.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/template/config_flow.py b/homeassistant/components/template/config_flow.py index 15be2c52d9114c..c361b4c42cc817 100644 --- a/homeassistant/components/template/config_flow.py +++ b/homeassistant/components/template/config_flow.py @@ -24,7 +24,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import selector +from homeassistant.helpers import entity_registry as er, selector from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -328,6 +328,7 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An return errors + entity_registry_entry: er.RegistryEntry | None = None if msg["flow_type"] == "config_flow": flow_status = hass.config_entries.flow.async_get(msg["flow_id"]) template_type = flow_status["step_id"] @@ -342,6 +343,12 @@ def _validate(schema: vol.Schema, domain: str, user_input: dict[str, Any]) -> An template_type = config_entry.options["template_type"] name = config_entry.options["name"] schema = cast(vol.Schema, OPTIONS_FLOW[template_type].schema) + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry( + entity_registry, flow_status["handler"] + ) + if entries: + entity_registry_entry = entries[0] errors = _validate(schema, template_type, msg["user_input"]) @@ -382,6 +389,7 @@ def async_preview_updated( _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 connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( From c9a6ea94a7db9a45826cf5e7091f852a86177c11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 6 Sep 2023 16:07:05 +0200 Subject: [PATCH 161/640] Send template render errors to template helper preview (#99716) --- .../components/template/template_entity.py | 23 +-- homeassistant/helpers/event.py | 13 +- tests/components/template/test_config_flow.py | 173 +++++++++++++++++- 3 files changed, 190 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/template/template_entity.py b/homeassistant/components/template/template_entity.py index 2ce42083117b9d..8c3554c067e601 100644 --- a/homeassistant/components/template/template_entity.py +++ b/homeassistant/components/template/template_entity.py @@ -15,13 +15,11 @@ CONF_ICON, CONF_ICON_TEMPLATE, CONF_NAME, - EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, ) from homeassistant.core import ( CALLBACK_TYPE, Context, - CoreState, HomeAssistant, State, callback, @@ -38,6 +36,7 @@ async_track_template_result, ) from homeassistant.helpers.script import Script, _VarsType +from homeassistant.helpers.start import async_at_start from homeassistant.helpers.template import ( Template, TemplateStateFromEntityId, @@ -442,7 +441,11 @@ def _handle_results( ) @callback - def _async_template_startup(self, *_: Any) -> None: + def _async_template_startup( + self, + _hass: HomeAssistant | None, + log_fn: Callable[[int, str], None] | None = None, + ) -> None: template_var_tups: list[TrackTemplate] = [] has_availability_template = False @@ -467,6 +470,7 @@ def _async_template_startup(self, *_: Any) -> None: self.hass, template_var_tups, self._handle_results, + log_fn=log_fn, has_super_template=has_availability_template, ) self.async_on_remove(result_info.async_remove) @@ -515,10 +519,13 @@ def async_start_preview( ) -> CALLBACK_TYPE: """Render a preview.""" + def log_template_error(level: int, msg: str) -> None: + preview_callback(None, None, None, msg) + self._preview_callback = preview_callback self._async_setup_templates() try: - self._async_template_startup() + self._async_template_startup(None, log_template_error) except Exception as err: # pylint: disable=broad-exception-caught preview_callback(None, None, None, str(err)) return self._call_on_remove_callbacks @@ -527,13 +534,7 @@ async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" self._async_setup_templates() - if self.hass.state == CoreState.running: - self._async_template_startup() - return - - self.hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_START, self._async_template_startup - ) + async_at_start(self.hass, self._async_template_startup) async def async_update(self) -> None: """Call for forced update.""" diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 22e274a7d0f797..1f74de497e2fd4 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -957,11 +957,14 @@ def async_setup( if info.exception: if raise_on_template_error: raise info.exception - _LOGGER.error( - "Error while processing template: %s", - track_template_.template, - exc_info=info.exception, - ) + if not log_fn: + _LOGGER.error( + "Error while processing template: %s", + track_template_.template, + exc_info=info.exception, + ) + else: + log_fn(logging.ERROR, str(info.exception)) self._track_state_changes = async_track_state_change_filtered( self.hass, _render_infos_to_track_states(self._info.values()), self._refresh diff --git a/tests/components/template/test_config_flow.py b/tests/components/template/test_config_flow.py index b8634b68b1c74d..f4cfe90b9f0344 100644 --- a/tests/components/template/test_config_flow.py +++ b/tests/components/template/test_config_flow.py @@ -272,12 +272,12 @@ async def test_options( ), ( "sensor", - "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + "{{ float(states('sensor.one'), default='') + float(states('sensor.two'), default='') }}", {}, {"one": "30.0", "two": "20.0"}, - ["unavailable", "50.0"], + ["", "50.0"], [{}, {}], - [["one"], ["one", "two"]], + [["one", "two"], ["one", "two"]], ), ), ) @@ -470,6 +470,173 @@ async def test_config_flow_preview_bad_input( } +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) + float(states('sensor.two')) }}", + {"one": "30.0", "two": "20.0"}, + ["unavailable", "50.0"], + [ + ( + "ValueError: Template error: float got invalid input 'unknown' " + "when rendering template '{{ float(states('sensor.one')) + " + "float(states('sensor.two')) }}' but no default was specified" + ) + ], + ), + ], +) +async def test_config_flow_preview_template_startup_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: dict[str, str], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + 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": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[input_entity], {} + ) + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + +@pytest.mark.parametrize( + ( + "template_type", + "state_template", + "input_states", + "template_states", + "error_events", + ), + [ + ( + "sensor", + "{{ float(states('sensor.one')) > 30 and undefined_function() }}", + [{"one": "30.0", "two": "20.0"}, {"one": "35.0", "two": "20.0"}], + ["False", "unavailable"], + ["'undefined_function' is undefined"], + ), + ], +) +async def test_config_flow_preview_template_error( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + template_type: str, + state_template: str, + input_states: list[dict[str, str]], + template_states: list[str], + error_events: list[str], +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + + input_entities = ["one", "two"] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[0][input_entity], {} + ) + + 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": template_type}, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == template_type + assert result["errors"] is None + assert result["preview"] == "template" + + await client.send_json_auto_id( + { + "type": "template/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"name": "My template", "state": state_template}, + } + ) + msg = await client.receive_json() + assert msg["type"] == "result" + assert msg["success"] + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[0] + + for input_entity in input_entities: + hass.states.async_set( + f"{template_type}.{input_entity}", input_states[1][input_entity], {} + ) + + for error_event in error_events: + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"] == {"error": error_event} + + msg = await client.receive_json() + assert msg["type"] == "event" + assert msg["event"]["state"] == template_states[1] + + @pytest.mark.parametrize( ( "template_type", From 0b95e4ac17db194f347e4607097be43c02b2f02b Mon Sep 17 00:00:00 2001 From: David Knowles Date: Wed, 6 Sep 2023 10:51:27 -0400 Subject: [PATCH 162/640] Fix the Hydrawise status sensor (#99271) --- homeassistant/components/hydrawise/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/hydrawise/binary_sensor.py b/homeassistant/components/hydrawise/binary_sensor.py index 63fe28cd40048d..9298e605791e86 100644 --- a/homeassistant/components/hydrawise/binary_sensor.py +++ b/homeassistant/components/hydrawise/binary_sensor.py @@ -90,7 +90,7 @@ def _handle_coordinator_update(self) -> None: """Get the latest data and updates the state.""" LOGGER.debug("Updating Hydrawise binary sensor: %s", self.name) if self.entity_description.key == "status": - self._attr_is_on = self.coordinator.api.status == "All good!" + 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" From 5d5466080264dab495f5c83731db6049760d018b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:53:41 +0200 Subject: [PATCH 163/640] Fix asyncio.wait typing (#99726) --- .../components/bluetooth_tracker/device_tracker.py | 3 +-- homeassistant/core.py | 13 ++++--------- 2 files changed, 5 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/bluetooth_tracker/device_tracker.py b/homeassistant/components/bluetooth_tracker/device_tracker.py index 4bfbe72d8b5e5c..6fecc428c10b65 100644 --- a/homeassistant/components/bluetooth_tracker/device_tracker.py +++ b/homeassistant/components/bluetooth_tracker/device_tracker.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Awaitable from datetime import datetime, timedelta import logging from typing import Final @@ -152,7 +151,7 @@ async def async_setup_scanner( async def perform_bluetooth_update() -> None: """Discover Bluetooth devices and update status.""" _LOGGER.debug("Performing Bluetooth devices discovery and update") - tasks: list[Awaitable[None]] = [] + tasks: list[asyncio.Task[None]] = [] try: if track_new: diff --git a/homeassistant/core.py b/homeassistant/core.py index 3648fca99f73ba..2ffe51a4f3adfd 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,14 +6,7 @@ from __future__ import annotations import asyncio -from collections.abc import ( - Awaitable, - Callable, - Collection, - Coroutine, - Iterable, - Mapping, -) +from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping import concurrent.futures from contextlib import suppress import datetime @@ -714,7 +707,9 @@ async def async_block_till_done(self) -> None: for task in tasks: _LOGGER.debug("Waiting for task: %s", task) - async def _await_and_log_pending(self, pending: Collection[Awaitable[Any]]) -> None: + async def _await_and_log_pending( + self, pending: Collection[asyncio.Future[Any]] + ) -> None: """Await and log tasks that take a long time.""" wait_time = 0 while pending: From d8035ddf474090258fc2d1785e38ef0726e2beb2 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:57:13 +0200 Subject: [PATCH 164/640] Fix tradfri asyncio.wait (#99730) --- homeassistant/components/tradfri/config_flow.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/tradfri/config_flow.py b/homeassistant/components/tradfri/config_flow.py index 2a3052c1f7bd38..a383cc2bbee0a2 100644 --- a/homeassistant/components/tradfri/config_flow.py +++ b/homeassistant/components/tradfri/config_flow.py @@ -122,7 +122,7 @@ async def _entry_from_data(self, data: dict[str, Any]) -> FlowResult: if same_hub_entries: await asyncio.wait( [ - self.hass.config_entries.async_remove(entry_id) + asyncio.create_task(self.hass.config_entries.async_remove(entry_id)) for entry_id in same_hub_entries ] ) From 2628a86864a517a24f1c8f8060dc5d9d238c15db Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 16:58:57 +0200 Subject: [PATCH 165/640] Update pre-commit to 3.4.0 (#99737) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 89db04a5db83cc..35d80233c3d42f 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -12,7 +12,7 @@ coverage==7.3.0 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 -pre-commit==3.3.3 +pre-commit==3.4.0 pydantic==1.10.12 pylint==2.17.4 pylint-per-file-ignores==1.2.1 From ab3bc1b74b98e488653a4e9a435bfbf05c4080ac Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:00:16 +0200 Subject: [PATCH 166/640] Improve blink config_flow typing (#99579) --- homeassistant/components/blink/config_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/blink/config_flow.py b/homeassistant/components/blink/config_flow.py index 445a84f838cd61..d3b2878b52266d 100644 --- a/homeassistant/components/blink/config_flow.py +++ b/homeassistant/components/blink/config_flow.py @@ -59,7 +59,7 @@ def validate_input(auth: Auth) -> None: raise Require2FA -def _send_blink_2fa_pin(auth: Auth, pin: str) -> bool: +def _send_blink_2fa_pin(auth: Auth, pin: str | None) -> bool: """Send 2FA pin to blink servers.""" blink = Blink() blink.auth = auth @@ -122,8 +122,9 @@ async def async_step_2fa( """Handle 2FA step.""" errors = {} if user_input is not None: - pin = user_input.get(CONF_PIN) + pin: str | None = 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 ) From eab76fc6212b7419de5a59de31c0021baa78e43c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 17:16:40 +0200 Subject: [PATCH 167/640] Revert "Bump pyoverkiz to 1.10.1 (#97916)" (#99742) --- 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 8cf029adb549ad..d88996c7e024a0 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.10.1"], + "requirements": ["pyoverkiz==1.9.0"], "zeroconf": [ { "type": "_kizbox._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index 2e4499d5b3fece..f038ef9d24701e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1918,7 +1918,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a1b03834318a4..73af7791cd9dff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1428,7 +1428,7 @@ pyotgw==2.1.3 pyotp==2.8.0 # homeassistant.components.overkiz -pyoverkiz==1.10.1 +pyoverkiz==1.9.0 # homeassistant.components.openweathermap pyowm==3.2.0 From 8bfdc5d3d9afbfc450e67d062c10bcfec1d090f5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 6 Sep 2023 17:37:11 +0200 Subject: [PATCH 168/640] Update pytest-aiohttp to 1.0.5 (#99744) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 35d80233c3d42f..4095d6732c97eb 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -18,7 +18,7 @@ pylint==2.17.4 pylint-per-file-ignores==1.2.1 pipdeptree==2.11.0 pytest-asyncio==0.21.0 -pytest-aiohttp==1.0.4 +pytest-aiohttp==1.0.5 pytest-cov==4.1.0 pytest-freezer==0.4.8 pytest-socket==0.6.0 From f24c4ceab6b24d268f00c95537053374d31f9f08 Mon Sep 17 00:00:00 2001 From: James Smith Date: Wed, 6 Sep 2023 08:55:41 -0700 Subject: [PATCH 169/640] Enable strict typing for Climate component (#99301) Co-authored-by: Martin Hjelmare --- .strict-typing | 1 + homeassistant/components/climate/__init__.py | 7 ++++--- homeassistant/components/climate/device_condition.py | 4 ++-- homeassistant/components/climate/reproduce_state.py | 4 +++- mypy.ini | 10 ++++++++++ 5 files changed, 20 insertions(+), 6 deletions(-) diff --git a/.strict-typing b/.strict-typing index 4a4151ce606e14..f49e576a774892 100644 --- a/.strict-typing +++ b/.strict-typing @@ -88,6 +88,7 @@ homeassistant.components.camera.* homeassistant.components.canary.* homeassistant.components.clickatell.* homeassistant.components.clicksend.* +homeassistant.components.climate.* homeassistant.components.cloud.* homeassistant.components.configurator.* homeassistant.components.cover.* diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 907ff84491bb1f..dfc428a9bd06e0 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -242,8 +242,9 @@ def state(self) -> str | None: hvac_mode = self.hvac_mode if hvac_mode is None: return None + # Support hvac_mode as string for custom integration backwards compatibility if not isinstance(hvac_mode, HVACMode): - return HVACMode(hvac_mode).value + return HVACMode(hvac_mode).value # type: ignore[unreachable] return hvac_mode.value @property @@ -458,11 +459,11 @@ def swing_modes(self) -> list[str] | None: """ return self._attr_swing_modes - def set_temperature(self, **kwargs) -> None: + def set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" raise NotImplementedError() - async def async_set_temperature(self, **kwargs) -> None: + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" await self.hass.async_add_executor_job( ft.partial(self.set_temperature, **kwargs) diff --git a/homeassistant/components/climate/device_condition.py b/homeassistant/components/climate/device_condition.py index d9f1b240a9ae5b..57b9654651bcc8 100644 --- a/homeassistant/components/climate/device_condition.py +++ b/homeassistant/components/climate/device_condition.py @@ -92,9 +92,9 @@ def test_is_state(hass: HomeAssistant, variables: TemplateVarsType) -> bool: return False if config[CONF_TYPE] == "is_hvac_mode": - return state.state == config[const.ATTR_HVAC_MODE] + return bool(state.state == config[const.ATTR_HVAC_MODE]) - return ( + return bool( state.attributes.get(const.ATTR_PRESET_MODE) == config[const.ATTR_PRESET_MODE] ) diff --git a/homeassistant/components/climate/reproduce_state.py b/homeassistant/components/climate/reproduce_state.py index 0bbc6fce7ecce5..2897a956fc6907 100644 --- a/homeassistant/components/climate/reproduce_state.py +++ b/homeassistant/components/climate/reproduce_state.py @@ -38,7 +38,9 @@ async def _async_reproduce_states( ) -> None: """Reproduce component states.""" - async def call_service(service: str, keys: Iterable, data=None): + async def call_service( + service: str, keys: Iterable, data: dict[str, Any] | None = None + ) -> None: """Call service with set of attributes given.""" data = data or {} data["entity_id"] = state.entity_id diff --git a/mypy.ini b/mypy.ini index 14eb6bba841281..6303f2b2706765 100644 --- a/mypy.ini +++ b/mypy.ini @@ -641,6 +641,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.climate.*] +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.cloud.*] check_untyped_defs = true disallow_incomplete_defs = true From 54d92b649b035119ed71c822d9327f29d1a16927 Mon Sep 17 00:00:00 2001 From: mkmer Date: Wed, 6 Sep 2023 12:33:58 -0400 Subject: [PATCH 170/640] Raise error on open/close failure in Aladdin Connect (#99746) Raise error on open/close failure --- .../components/aladdin_connect/cover.py | 8 ++++--- .../components/aladdin_connect/test_cover.py | 24 +++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/aladdin_connect/cover.py b/homeassistant/components/aladdin_connect/cover.py index f466f5f4248e6f..604ac61300d4bb 100644 --- a/homeassistant/components/aladdin_connect/cover.py +++ b/homeassistant/components/aladdin_connect/cover.py @@ -10,7 +10,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_CLOSED, STATE_CLOSING, STATE_OPENING from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady +from homeassistant.exceptions import HomeAssistantError, PlatformNotReady from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -75,11 +75,13 @@ async def async_will_remove_from_hass(self) -> None: async def async_close_cover(self, **kwargs: Any) -> None: """Issue close command to cover.""" - await self._acc.close_door(self._device_id, self._number) + if not await self._acc.close_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to close the cover") async def async_open_cover(self, **kwargs: Any) -> None: """Issue open command to cover.""" - await self._acc.open_door(self._device_id, self._number) + if not await self._acc.open_door(self._device_id, self._number): + raise HomeAssistantError("Aladdin Connect API failed to open the cover") async def async_update(self) -> None: """Update status of cover.""" diff --git a/tests/components/aladdin_connect/test_cover.py b/tests/components/aladdin_connect/test_cover.py index eb617b959a5c11..ba82ec6589a60d 100644 --- a/tests/components/aladdin_connect/test_cover.py +++ b/tests/components/aladdin_connect/test_cover.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, MagicMock, patch from AIOAladdinConnect import session_manager +import pytest from homeassistant.components.aladdin_connect.const import DOMAIN from homeassistant.components.aladdin_connect.cover import SCAN_INTERVAL @@ -19,6 +20,7 @@ STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from homeassistant.util.dt import utcnow @@ -123,6 +125,17 @@ async def test_cover_operation( ) assert hass.states.get("cover.home").state == STATE_OPEN + mock_aladdinconnect_api.open_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_OPEN_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.open_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock(return_value=STATE_CLOSED) mock_aladdinconnect_api.get_door_status.return_value = STATE_CLOSED @@ -140,6 +153,17 @@ async def test_cover_operation( assert hass.states.get("cover.home").state == STATE_CLOSED + mock_aladdinconnect_api.close_door.return_value = False + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + COVER_DOMAIN, + SERVICE_CLOSE_COVER, + {ATTR_ENTITY_ID: "cover.home"}, + blocking=True, + ) + + mock_aladdinconnect_api.close_door.return_value = True + mock_aladdinconnect_api.async_get_door_status = AsyncMock( return_value=STATE_CLOSING ) From 9bc07f50f9f2e26a45343deb4537a4403e4401cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=C3=A1=C5=A1=20Hol=C3=BD?= Date: Wed, 6 Sep 2023 18:39:33 +0200 Subject: [PATCH 171/640] Add additional fields for 3-phase UPS to nut (#98625) Co-authored-by: J. Nick Koston --- homeassistant/components/nut/sensor.py | 321 ++++++++++++++++++++++ homeassistant/components/nut/strings.json | 42 +++ 2 files changed, 363 insertions(+) diff --git a/homeassistant/components/nut/sensor.py b/homeassistant/components/nut/sensor.py index 9151a86a9f871e..165db8bb704b2f 100644 --- a/homeassistant/components/nut/sensor.py +++ b/homeassistant/components/nut/sensor.py @@ -491,6 +491,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1-N.voltage": SensorEntityDescription( + key="input.L1-N.voltage", + translation_key="input_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2-N.voltage": SensorEntityDescription( + key="input.L2-N.voltage", + translation_key="input_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3-N.voltage": SensorEntityDescription( + key="input.L3-N.voltage", + translation_key="input_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.frequency": SensorEntityDescription( key="input.frequency", translation_key="input_frequency", @@ -515,6 +542,69 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.frequency": SensorEntityDescription( + key="input.L1.frequency", + translation_key="input_l1_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.frequency": SensorEntityDescription( + key="input.L2.frequency", + translation_key="input_l2_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.frequency": SensorEntityDescription( + key="input.L3.frequency", + translation_key="input_l3_frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.current": SensorEntityDescription( + key="input.bypass.current", + translation_key="input_bypass_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.current": SensorEntityDescription( + key="input.bypass.L1.current", + translation_key="input_bypass_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.current": SensorEntityDescription( + key="input.bypass.L2.current", + translation_key="input_bypass_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.current": SensorEntityDescription( + key="input.bypass.L3.current", + translation_key="input_bypass_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.bypass.frequency": SensorEntityDescription( key="input.bypass.frequency", translation_key="input_bypass_frequency", @@ -531,6 +621,78 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.bypass.realpower": SensorEntityDescription( + key="input.bypass.realpower", + translation_key="input_bypass_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1.realpower": SensorEntityDescription( + key="input.bypass.L1.realpower", + translation_key="input_bypass_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2.realpower": SensorEntityDescription( + key="input.bypass.L2.realpower", + translation_key="input_bypass_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3.realpower": SensorEntityDescription( + key="input.bypass.L3.realpower", + translation_key="input_bypass_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.voltage": SensorEntityDescription( + key="input.bypass.voltage", + translation_key="input_bypass_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L1-N.voltage": SensorEntityDescription( + key="input.bypass.L1-N.voltage", + translation_key="input_bypass_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L2-N.voltage": SensorEntityDescription( + key="input.bypass.L2-N.voltage", + translation_key="input_bypass_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.bypass.L3-N.voltage": SensorEntityDescription( + key="input.bypass.L3-N.voltage", + translation_key="input_bypass_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.current": SensorEntityDescription( key="input.current", translation_key="input_current", @@ -540,6 +702,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.current": SensorEntityDescription( + key="input.L1.current", + translation_key="input_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.current": SensorEntityDescription( + key="input.L2.current", + translation_key="input_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.current": SensorEntityDescription( + key="input.L3.current", + translation_key="input_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "input.phases": SensorEntityDescription( key="input.phases", translation_key="input_phases", @@ -556,6 +745,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "input.L1.realpower": SensorEntityDescription( + key="input.L1.realpower", + translation_key="input_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L2.realpower": SensorEntityDescription( + key="input.L2.realpower", + translation_key="input_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "input.L3.realpower": SensorEntityDescription( + key="input.L3.realpower", + translation_key="input_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.power.nominal": SensorEntityDescription( key="output.power.nominal", translation_key="output_power_nominal", @@ -564,6 +780,30 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.power.percent": SensorEntityDescription( + key="output.L1.power.percent", + translation_key="output_l1_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.power.percent": SensorEntityDescription( + key="output.L2.power.percent", + translation_key="output_l2_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.power.percent": SensorEntityDescription( + key="output.L3.power.percent", + translation_key="output_l3_power_percent", + native_unit_of_measurement=PERCENTAGE, + icon="mdi:gauge", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.current": SensorEntityDescription( key="output.current", translation_key="output_current", @@ -581,6 +821,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.current": SensorEntityDescription( + key="output.L1.current", + translation_key="output_l1_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.current": SensorEntityDescription( + key="output.L2.current", + translation_key="output_l2_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.current": SensorEntityDescription( + key="output.L3.current", + translation_key="output_l3_current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.voltage": SensorEntityDescription( key="output.voltage", translation_key="output_voltage", @@ -596,6 +863,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1-N.voltage": SensorEntityDescription( + key="output.L1-N.voltage", + translation_key="output_l1_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2-N.voltage": SensorEntityDescription( + key="output.L2-N.voltage", + translation_key="output_l2_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3-N.voltage": SensorEntityDescription( + key="output.L3-N.voltage", + translation_key="output_l3_n_voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "output.frequency": SensorEntityDescription( key="output.frequency", translation_key="output_frequency", @@ -646,6 +940,33 @@ entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, ), + "output.L1.realpower": SensorEntityDescription( + key="output.L1.realpower", + translation_key="output_l1_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L2.realpower": SensorEntityDescription( + key="output.L2.realpower", + translation_key="output_l2_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), + "output.L3.realpower": SensorEntityDescription( + key="output.L3.realpower", + translation_key="output_l3_realpower", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + ), "ambient.humidity": SensorEntityDescription( key="ambient.humidity", translation_key="ambient_humidity", diff --git a/homeassistant/components/nut/strings.json b/homeassistant/components/nut/strings.json index a07e0ec2f7c847..2827911a3aa318 100644 --- a/homeassistant/components/nut/strings.json +++ b/homeassistant/components/nut/strings.json @@ -90,31 +90,73 @@ "battery_voltage_high": { "name": "High battery voltage" }, "battery_voltage_low": { "name": "Low battery voltage" }, "battery_voltage_nominal": { "name": "Nominal battery voltage" }, + "input_bypass_current": { "name": "Input bypass current" }, + "input_bypass_l1_current": { "name": "Input bypass L1 current" }, + "input_bypass_l2_current": { "name": "Input bypass L2 current" }, + "input_bypass_l3_current": { "name": "Input bypass L3 current" }, + "input_bypass_voltage": { "name": "Input bypass voltage" }, + "input_bypass_l1_n_voltage": { "name": "Input bypass L1-N voltage" }, + "input_bypass_l2_n_voltage": { "name": "Input bypass L2-N voltage" }, + "input_bypass_l3_n_voltage": { "name": "Input bypass L3-N voltage" }, "input_bypass_frequency": { "name": "Input bypass frequency" }, "input_bypass_phases": { "name": "Input bypass phases" }, + "input_bypass_realpower": { "name": "Current input bypass real power" }, + "input_bypass_l1_realpower": { + "name": "Current input bypass L1 real power" + }, + "input_bypass_l2_realpower": { + "name": "Current input bypass L2 real power" + }, + "input_bypass_l3_realpower": { + "name": "Current input bypass L3 real power" + }, "input_current": { "name": "Input current" }, + "input_l1_current": { "name": "Input L1 current" }, + "input_l2_current": { "name": "Input L2 current" }, + "input_l3_current": { "name": "Input L3 current" }, "input_frequency": { "name": "Input line frequency" }, "input_frequency_nominal": { "name": "Nominal input line frequency" }, "input_frequency_status": { "name": "Input frequency status" }, + "input_l1_frequency": { "name": "Input L1 line frequency" }, + "input_l2_frequency": { "name": "Input L2 line frequency" }, + "input_l3_frequency": { "name": "Input L3 line frequency" }, "input_phases": { "name": "Input phases" }, "input_realpower": { "name": "Current input real power" }, + "input_l1_realpower": { "name": "Current input L1 real power" }, + "input_l2_realpower": { "name": "Current input L2 real power" }, + "input_l3_realpower": { "name": "Current input L3 real power" }, "input_sensitivity": { "name": "Input power sensitivity" }, "input_transfer_high": { "name": "High voltage transfer" }, "input_transfer_low": { "name": "Low voltage transfer" }, "input_transfer_reason": { "name": "Voltage transfer reason" }, "input_voltage": { "name": "Input voltage" }, "input_voltage_nominal": { "name": "Nominal input voltage" }, + "input_l1_n_voltage": { "name": "Input L1 voltage" }, + "input_l2_n_voltage": { "name": "Input L2 voltage" }, + "input_l3_n_voltage": { "name": "Input L3 voltage" }, "output_current": { "name": "Output current" }, "output_current_nominal": { "name": "Nominal output current" }, + "output_l1_current": { "name": "Output L1 current" }, + "output_l2_current": { "name": "Output L2 current" }, + "output_l3_current": { "name": "Output L3 current" }, "output_frequency": { "name": "Output frequency" }, "output_frequency_nominal": { "name": "Nominal output frequency" }, "output_phases": { "name": "Output phases" }, "output_power": { "name": "Output apparent power" }, + "output_l2_power_percent": { "name": "Output L2 power usage" }, + "output_l1_power_percent": { "name": "Output L1 power usage" }, + "output_l3_power_percent": { "name": "Output L3 power usage" }, "output_power_nominal": { "name": "Nominal output power" }, "output_realpower": { "name": "Current output real power" }, "output_realpower_nominal": { "name": "Nominal output real power" }, + "output_l1_realpower": { "name": "Current output L1 real power" }, + "output_l2_realpower": { "name": "Current output L2 real power" }, + "output_l3_realpower": { "name": "Current output L3 real power" }, "output_voltage": { "name": "Output voltage" }, "output_voltage_nominal": { "name": "Nominal output voltage" }, + "output_l1_n_voltage": { "name": "Output L1-N voltage" }, + "output_l2_n_voltage": { "name": "Output L2-N voltage" }, + "output_l3_n_voltage": { "name": "Output L3-N voltage" }, "ups_alarm": { "name": "Alarms" }, "ups_beeper_status": { "name": "Beeper status" }, "ups_contacts": { "name": "External contacts" }, From 7a6c8767b3f3a06d59dfac1ccdc5cb3ce7733687 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 6 Sep 2023 18:51:38 +0200 Subject: [PATCH 172/640] Improve typing of trend component (#99719) * Some typing in trend component * Add missing type hint * Enable strict typing on trend --- .strict-typing | 1 + .../components/trend/binary_sensor.py | 37 ++++++++++--------- mypy.ini | 10 +++++ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/.strict-typing b/.strict-typing index f49e576a774892..30d20a6fc545e5 100644 --- a/.strict-typing +++ b/.strict-typing @@ -336,6 +336,7 @@ homeassistant.components.trafikverket_camera.* homeassistant.components.trafikverket_ferry.* homeassistant.components.trafikverket_train.* homeassistant.components.trafikverket_weatherstation.* +homeassistant.components.trend.* homeassistant.components.tts.* homeassistant.components.twentemilieu.* homeassistant.components.unifi.* diff --git a/homeassistant/components/trend/binary_sensor.py b/homeassistant/components/trend/binary_sensor.py index 815403e1e87af2..089e82b0f0759c 100644 --- a/homeassistant/components/trend/binary_sensor.py +++ b/homeassistant/components/trend/binary_sensor.py @@ -2,8 +2,10 @@ from __future__ import annotations from collections import deque +from collections.abc import Mapping import logging import math +from typing import Any import numpy as np import voluptuous as vol @@ -12,6 +14,7 @@ DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, + BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.const import ( @@ -117,20 +120,22 @@ class SensorTrend(BinarySensorEntity): """Representation of a trend Sensor.""" _attr_should_poll = False + _gradient = 0.0 + _state: bool | None = None def __init__( self, - hass, - device_id, - friendly_name, - entity_id, - attribute, - device_class, - invert, - max_samples, - min_gradient, - sample_duration, - ): + hass: HomeAssistant, + device_id: str, + friendly_name: str, + entity_id: str, + attribute: str, + device_class: BinarySensorDeviceClass, + invert: bool, + max_samples: int, + min_gradient: float, + sample_duration: int, + ) -> None: """Initialize the sensor.""" self._hass = hass self.entity_id = generate_entity_id(ENTITY_ID_FORMAT, device_id, hass=hass) @@ -141,17 +146,15 @@ def __init__( self._invert = invert self._sample_duration = sample_duration self._min_gradient = min_gradient - self._gradient = None - self._state = None - self.samples = deque(maxlen=max_samples) + self.samples: deque = deque(maxlen=max_samples) @property - def is_on(self): + def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> Mapping[str, Any]: """Return the state attributes of the sensor.""" return { ATTR_ENTITY_ID: self._entity_id, @@ -214,7 +217,7 @@ async def async_update(self) -> None: if self._invert: self._state = not self._state - def _calculate_gradient(self): + def _calculate_gradient(self) -> None: """Compute the linear trend gradient of the current samples. This need run inside executor. diff --git a/mypy.ini b/mypy.ini index 6303f2b2706765..1c3fc1a52ed9e3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3123,6 +3123,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.trend.*] +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.tts.*] check_untyped_defs = true disallow_incomplete_defs = true From 7c7456df99f2ab9bc51a2076d573e23a2ea40e54 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Sep 2023 18:54:16 +0200 Subject: [PATCH 173/640] Handle alexa invalid climate temp adjustment (#99740) * Handle temp adjust when target state not set * Update homeassistant/components/alexa/errors.py Co-authored-by: Robert Resch * black --------- Co-authored-by: Robert Resch --- homeassistant/components/alexa/errors.py | 7 +++ homeassistant/components/alexa/handlers.py | 9 ++- tests/components/alexa/test_smart_home.py | 69 ++++++++++++++++++++++ 3 files changed, 84 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/alexa/errors.py b/homeassistant/components/alexa/errors.py index 2c5ced62403a6a..f8e3720e16026f 100644 --- a/homeassistant/components/alexa/errors.py +++ b/homeassistant/components/alexa/errors.py @@ -90,6 +90,13 @@ class AlexaUnsupportedThermostatModeError(AlexaError): error_type = "UNSUPPORTED_THERMOSTAT_MODE" +class AlexaUnsupportedThermostatTargetStateError(AlexaError): + """Class to represent unsupported climate target state error.""" + + namespace = "Alexa.ThermostatController" + error_type = "INVALID_TARGET_STATE" + + class AlexaTempRangeError(AlexaError): """Class to represent TempRange errors.""" diff --git a/homeassistant/components/alexa/handlers.py b/homeassistant/components/alexa/handlers.py index 3e995e9ffe205a..f99b0231e4d4be 100644 --- a/homeassistant/components/alexa/handlers.py +++ b/homeassistant/components/alexa/handlers.py @@ -73,6 +73,7 @@ AlexaSecurityPanelAuthorizationRequired, AlexaTempRangeError, AlexaUnsupportedThermostatModeError, + AlexaUnsupportedThermostatTargetStateError, AlexaVideoActionNotPermittedForContentError, ) from .state_report import AlexaDirective, AlexaResponse, async_enable_proactive_mode @@ -911,7 +912,13 @@ async def async_api_adjust_target_temp( } ) else: - target_temp = float(entity.attributes[ATTR_TEMPERATURE]) + temp_delta + current_target_temp: str | None = entity.attributes.get(ATTR_TEMPERATURE) + if current_target_temp is None: + raise AlexaUnsupportedThermostatTargetStateError( + "The current target temperature is not set, " + "cannot adjust target temperature" + ) + target_temp = float(current_target_temp) + temp_delta if target_temp < min_temp or target_temp > max_temp: raise AlexaTempRangeError(hass, target_temp, min_temp, max_temp) diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index c42ea0a0f6a549..bbdf3efeb5fee9 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -2471,6 +2471,75 @@ async def test_thermostat(hass: HomeAssistant) -> None: assert call.data["preset_mode"] == "eco" +async def test_no_current_target_temp_adjusting_temp(hass: HomeAssistant) -> None: + """Test thermostat adjusting temp with no initial target temperature.""" + hass.config.units = US_CUSTOMARY_SYSTEM + device = ( + "climate.test_thermostat", + "cool", + { + "temperature": None, + "target_temp_high": None, + "target_temp_low": None, + "current_temperature": 75.0, + "friendly_name": "Test Thermostat", + "supported_features": 1 | 2 | 4 | 128, + "hvac_modes": ["off", "heat", "cool", "auto", "dry", "fan_only"], + "preset_mode": None, + "preset_modes": ["eco"], + "min_temp": 50, + "max_temp": 90, + }, + ) + appliance = await discovery_test(device, hass) + + assert appliance["endpointId"] == "climate#test_thermostat" + assert appliance["displayCategories"][0] == "THERMOSTAT" + assert appliance["friendlyName"] == "Test Thermostat" + + capabilities = assert_endpoint_capabilities( + appliance, + "Alexa.PowerController", + "Alexa.ThermostatController", + "Alexa.TemperatureSensor", + "Alexa.EndpointHealth", + "Alexa", + ) + + properties = await reported_properties(hass, "climate#test_thermostat") + properties.assert_equal("Alexa.ThermostatController", "thermostatMode", "COOL") + properties.assert_not_has_property( + "Alexa.ThermostatController", + "targetSetpoint", + ) + properties.assert_equal( + "Alexa.TemperatureSensor", "temperature", {"value": 75.0, "scale": "FAHRENHEIT"} + ) + + thermostat_capability = get_capability(capabilities, "Alexa.ThermostatController") + assert thermostat_capability is not None + configuration = thermostat_capability["configuration"] + assert configuration["supportsScheduling"] is False + + supported_modes = ["OFF", "HEAT", "COOL", "AUTO", "ECO", "CUSTOM"] + for mode in supported_modes: + assert mode in configuration["supportedModes"] + + # Adjust temperature where target temp is not set + msg = await assert_request_fails( + "Alexa.ThermostatController", + "AdjustTargetTemperature", + "climate#test_thermostat", + "climate.set_temperature", + hass, + payload={"targetSetpointDelta": {"value": -5.0, "scale": "KELVIN"}}, + ) + assert msg["event"]["payload"]["type"] == "INVALID_TARGET_STATE" + assert msg["event"]["payload"]["message"] == ( + "The current target temperature is not set, cannot adjust target temperature" + ) + + async def test_thermostat_dual(hass: HomeAssistant) -> None: """Test thermostat discovery with auto mode, with upper and lower target temperatures.""" hass.config.units = US_CUSTOMARY_SYSTEM From fdf902e0532d478585229d0c99a3eaf53fcac599 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 12:37:42 -0500 Subject: [PATCH 174/640] Bump zeroconf to 0.98.0 (#99748) --- 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 4969b2a5a65188..117744a2775e03 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.97.0"] + "requirements": ["zeroconf==0.98.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index e0d75c1ec20d34..0022f0bc037fa2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.97.0 +zeroconf==0.98.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 f038ef9d24701e..cb4b40d4172e02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2768,7 +2768,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.97.0 +zeroconf==0.98.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 73af7791cd9dff..b92f5483cc8c1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2041,7 +2041,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.97.0 +zeroconf==0.98.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 533350b94ab766c8e8c7868a1404a1c26769d7b8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 13:21:21 -0500 Subject: [PATCH 175/640] Bump dbus-fast to 1.95.0 (#99749) --- 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 e1a5ee41324a8c..bcb371971a6f45 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.94.1" + "dbus-fast==1.95.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0022f0bc037fa2..0624415b11c491 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.94.1 +dbus-fast==1.95.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index cb4b40d4172e02..b959d43886fa4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==1.95.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b92f5483cc8c1c..c9dc2f9184fc19 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.94.1 +dbus-fast==1.95.0 # homeassistant.components.debugpy debugpy==1.6.7 From b0e46f425f9147d8117cd50a0c8c31ca0dafd39d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 21:50:48 +0200 Subject: [PATCH 176/640] Remove deprecated entities from OpenTherm Gateway (#99712) --- .../components/opentherm_gw/binary_sensor.py | 58 ---------- .../components/opentherm_gw/const.py | 103 ------------------ .../components/opentherm_gw/sensor.py | 67 +----------- 3 files changed, 1 insertion(+), 227 deletions(-) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 2501d00c2eb835..7f2a05ddf03a4d 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -1,12 +1,10 @@ """Support for OpenTherm Gateway binary sensors.""" import logging -from pprint import pformat from homeassistant.components.binary_sensor import ENTITY_ID_FORMAT, BinarySensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id @@ -17,7 +15,6 @@ BINARY_SENSOR_INFO, DATA_GATEWAYS, DATA_OPENTHERM_GW, - DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP, TRANSLATE_SOURCE, ) @@ -31,9 +28,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway binary sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in BINARY_SENSOR_INFO.items(): device_class = info[0] friendly_name_format = info[1] @@ -50,36 +45,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermBinarySensor( - gw_dev, - var, - device_class, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following binary_sensor entities are deprecated and may " - "no longer behave as expected. They will be removed in a " - "future version. You can force removal of these entities by " - "disabling them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -166,26 +131,3 @@ def is_on(self): def device_class(self): """Return the class of this device.""" return self._device_class - - -class DeprecatedOpenThermBinarySensor(OpenThermBinarySensor): - """Represent a deprecated OpenTherm Gateway Binary Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, friendly_name_format): - """Initialize the binary sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP[var] - self._state = None - self._device_class = device_class - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" diff --git a/homeassistant/components/opentherm_gw/const.py b/homeassistant/components/opentherm_gw/const.py index 1532b7877406a8..a6c75c171136ae 100644 --- a/homeassistant/components/opentherm_gw/const.py +++ b/homeassistant/components/opentherm_gw/const.py @@ -535,106 +535,3 @@ [gw_vars.OTGW], ], } - -DEPRECATED_BINARY_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_MASTER_CH_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_DHW_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_OTC_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_CH2_ENABLED: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_FAULT_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_FLAME_ON: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_ACTIVE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DIAG_IND: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CONTROL_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_COOLING_SUPPORTED: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_CONFIG: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MASTER_LOW_OFF_PUMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH2_PRESENT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_SERVICE_REQ: gw_vars.BOILER, - gw_vars.DATA_SLAVE_REMOTE_RESET: gw_vars.BOILER, - gw_vars.DATA_SLAVE_LOW_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_SLAVE_GAS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_AIR_PRESS_FAULT: gw_vars.BOILER, - gw_vars.DATA_SLAVE_WATER_OVERTEMP: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_TRANSFER_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_DHW: gw_vars.BOILER, - gw_vars.DATA_REMOTE_RW_MAX_CH: gw_vars.BOILER, - gw_vars.DATA_ROVRD_MAN_PRIO: gw_vars.THERMOSTAT, - gw_vars.DATA_ROVRD_AUTO_PRIO: gw_vars.THERMOSTAT, - gw_vars.OTGW_GPIO_A_STATE: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B_STATE: gw_vars.OTGW, - gw_vars.OTGW_IGNORE_TRANSITIONS: gw_vars.OTGW, - gw_vars.OTGW_OVRD_HB: gw_vars.OTGW, -} - -DEPRECATED_SENSOR_SOURCE_LOOKUP = { - gw_vars.DATA_CONTROL_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MASTER_MEMBERID: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MEMBERID: gw_vars.BOILER, - gw_vars.DATA_SLAVE_OEM_FAULT: gw_vars.BOILER, - gw_vars.DATA_COOLING_CONTROL: gw_vars.BOILER, - gw_vars.DATA_CONTROL_SETPOINT_2: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_OVRD: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_MAX_RELATIVE_MOD: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MAX_CAPACITY: gw_vars.BOILER, - gw_vars.DATA_SLAVE_MIN_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT: gw_vars.THERMOSTAT, - gw_vars.DATA_REL_MOD_LEVEL: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_PRESS: gw_vars.BOILER, - gw_vars.DATA_DHW_FLOW_RATE: gw_vars.BOILER, - gw_vars.DATA_ROOM_SETPOINT_2: gw_vars.THERMOSTAT, - gw_vars.DATA_ROOM_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_CH_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP: gw_vars.BOILER, - gw_vars.DATA_OUTSIDE_TEMP: gw_vars.THERMOSTAT, - gw_vars.DATA_RETURN_WATER_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_STORAGE_TEMP: gw_vars.BOILER, - gw_vars.DATA_SOLAR_COLL_TEMP: gw_vars.BOILER, - gw_vars.DATA_CH_WATER_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_DHW_TEMP_2: gw_vars.BOILER, - gw_vars.DATA_EXHAUST_TEMP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_DHW_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MAX_SETP: gw_vars.BOILER, - gw_vars.DATA_SLAVE_CH_MIN_SETP: gw_vars.BOILER, - gw_vars.DATA_DHW_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_MAX_CH_SETPOINT: gw_vars.BOILER, - gw_vars.DATA_OEM_DIAG: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_STARTS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_STARTS: gw_vars.BOILER, - gw_vars.DATA_TOTAL_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_CH_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_PUMP_HOURS: gw_vars.BOILER, - gw_vars.DATA_DHW_BURNER_HOURS: gw_vars.BOILER, - gw_vars.DATA_MASTER_OT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_OT_VERSION: gw_vars.BOILER, - gw_vars.DATA_MASTER_PRODUCT_TYPE: gw_vars.THERMOSTAT, - gw_vars.DATA_MASTER_PRODUCT_VERSION: gw_vars.THERMOSTAT, - gw_vars.DATA_SLAVE_PRODUCT_TYPE: gw_vars.BOILER, - gw_vars.DATA_SLAVE_PRODUCT_VERSION: gw_vars.BOILER, - gw_vars.OTGW_MODE: gw_vars.OTGW, - gw_vars.OTGW_DHW_OVRD: gw_vars.OTGW, - gw_vars.OTGW_ABOUT: gw_vars.OTGW, - gw_vars.OTGW_BUILD: gw_vars.OTGW, - gw_vars.OTGW_CLOCKMHZ: gw_vars.OTGW, - gw_vars.OTGW_LED_A: gw_vars.OTGW, - gw_vars.OTGW_LED_B: gw_vars.OTGW, - gw_vars.OTGW_LED_C: gw_vars.OTGW, - gw_vars.OTGW_LED_D: gw_vars.OTGW, - gw_vars.OTGW_LED_E: gw_vars.OTGW, - gw_vars.OTGW_LED_F: gw_vars.OTGW, - gw_vars.OTGW_GPIO_A: gw_vars.OTGW, - gw_vars.OTGW_GPIO_B: gw_vars.OTGW, - gw_vars.OTGW_SB_TEMP: gw_vars.OTGW, - gw_vars.OTGW_SETP_OVRD_MODE: gw_vars.OTGW, - gw_vars.OTGW_SMART_PWR: gw_vars.OTGW, - gw_vars.OTGW_THRM_DETECT: gw_vars.OTGW, - gw_vars.OTGW_VREF: gw_vars.OTGW, -} diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index b219969e71ae1b..df9260d7d19275 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -1,25 +1,17 @@ """Support for OpenTherm Gateway sensors.""" import logging -from pprint import pformat from homeassistant.components.sensor import ENTITY_ID_FORMAT, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import DOMAIN -from .const import ( - DATA_GATEWAYS, - DATA_OPENTHERM_GW, - DEPRECATED_SENSOR_SOURCE_LOOKUP, - SENSOR_INFO, - TRANSLATE_SOURCE, -) +from .const import DATA_GATEWAYS, DATA_OPENTHERM_GW, SENSOR_INFO, TRANSLATE_SOURCE _LOGGER = logging.getLogger(__name__) @@ -31,9 +23,7 @@ async def async_setup_entry( ) -> None: """Set up the OpenTherm Gateway sensors.""" sensors = [] - deprecated_sensors = [] gw_dev = hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS][config_entry.data[CONF_ID]] - ent_reg = er.async_get(hass) for var, info in SENSOR_INFO.items(): device_class = info[0] unit = info[1] @@ -52,37 +42,6 @@ async def async_setup_entry( ) ) - old_style_entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - old_ent = ent_reg.async_get(old_style_entity_id) - if old_ent and old_ent.config_entry_id == config_entry.entry_id: - if old_ent.disabled: - ent_reg.async_remove(old_style_entity_id) - else: - deprecated_sensors.append( - DeprecatedOpenThermSensor( - gw_dev, - var, - device_class, - unit, - friendly_name_format, - ) - ) - - sensors.extend(deprecated_sensors) - - if deprecated_sensors: - _LOGGER.warning( - ( - "The following sensor entities are deprecated and may no " - "longer behave as expected. They will be removed in a future " - "version. You can force removal of these entities by disabling " - "them and restarting Home Assistant.\n%s" - ), - pformat([s.entity_id for s in deprecated_sensors]), - ) - async_add_entities(sensors) @@ -175,27 +134,3 @@ def native_value(self): def native_unit_of_measurement(self): """Return the unit of measurement.""" return self._unit - - -class DeprecatedOpenThermSensor(OpenThermSensor): - """Represent a deprecated OpenTherm Gateway Sensor.""" - - # pylint: disable=super-init-not-called - def __init__(self, gw_dev, var, device_class, unit, friendly_name_format): - """Initialize the OpenTherm Gateway sensor.""" - self.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, f"{var}_{gw_dev.gw_id}", hass=gw_dev.hass - ) - self._gateway = gw_dev - self._var = var - self._source = DEPRECATED_SENSOR_SOURCE_LOOKUP[var] - self._value = None - self._device_class = device_class - self._unit = unit - self._friendly_name = friendly_name_format.format(gw_dev.name) - self._unsub_updates = None - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._var}" From 61b02e9c66e127917a1682eb2ffe19a44d5946f6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 23:34:39 +0200 Subject: [PATCH 177/640] Use shorthand attributes in Progetti (#99772) Use shorthand attributes in Progetti shorthand --- homeassistant/components/progettihwsw/binary_sensor.py | 7 +------ homeassistant/components/progettihwsw/switch.py | 7 +------ 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/progettihwsw/binary_sensor.py b/homeassistant/components/progettihwsw/binary_sensor.py index e2d1025cc64a0c..ea7a7dce5c3db8 100644 --- a/homeassistant/components/progettihwsw/binary_sensor.py +++ b/homeassistant/components/progettihwsw/binary_sensor.py @@ -62,14 +62,9 @@ class ProgettihwswBinarySensor(CoordinatorEntity, BinarySensorEntity): def __init__(self, coordinator, name, sensor: Input) -> None: """Set initializing values.""" super().__init__(coordinator) - self._name = name + self._attr_name = name self._sensor = sensor - @property - def name(self): - """Return the sensor name.""" - return self._name - @property def is_on(self): """Get sensor state.""" diff --git a/homeassistant/components/progettihwsw/switch.py b/homeassistant/components/progettihwsw/switch.py index 77cfb6ba4d14bd..f466e11a1cca64 100644 --- a/homeassistant/components/progettihwsw/switch.py +++ b/homeassistant/components/progettihwsw/switch.py @@ -64,7 +64,7 @@ def __init__(self, coordinator, name, switch: Relay) -> None: """Initialize the values.""" super().__init__(coordinator) self._switch = switch - self._name = name + self._attr_name = name async def async_turn_on(self, **kwargs: Any) -> None: """Turn the switch on.""" @@ -81,11 +81,6 @@ async def async_toggle(self, **kwargs: Any) -> None: await self._switch.toggle() await self.coordinator.async_request_refresh() - @property - def name(self): - """Return the switch name.""" - return self._name - @property def is_on(self): """Get switch state.""" From 3afdecd51f70cf90c6e265f9697436509dc4e68c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Sep 2023 23:37:31 +0200 Subject: [PATCH 178/640] Use shorthand attributes in Plum (#99770) Use shorthand attributes in Plum shorthand --- .../components/plum_lightpad/light.py | 74 +++++-------------- 1 file changed, 20 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/plum_lightpad/light.py b/homeassistant/components/plum_lightpad/light.py index 2c1f7daa880400..9464e66e3a962c 100644 --- a/homeassistant/components/plum_lightpad/light.py +++ b/homeassistant/components/plum_lightpad/light.py @@ -73,6 +73,14 @@ def __init__(self, load): """Initialize the light.""" self._load = load self._brightness = load.level + unique_id = f"{load.llid}.light" + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Dimmer", + name=load.name, + ) async def async_added_to_hass(self) -> None: """Subscribe to dimmerchange events.""" @@ -83,21 +91,6 @@ def dimmerchange(self, event): self._brightness = event["level"] self.schedule_update_ha_state() - @property - def unique_id(self): - """Combine logical load ID with .light to guarantee it is unique.""" - return f"{self._load.llid}.light" - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Dimmer", - name=self._load.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" @@ -138,18 +131,27 @@ class GlowRing(LightEntity): _attr_color_mode = ColorMode.HS _attr_should_poll = False _attr_supported_color_modes = {ColorMode.HS} + _attr_icon = "mdi:crop-portrait" def __init__(self, lightpad): """Initialize the light.""" self._lightpad = lightpad - self._name = f"{lightpad.friendly_name} Glow Ring" + self._attr_name = f"{lightpad.friendly_name} Glow Ring" - self._state = lightpad.glow_enabled + self._attr_is_on = lightpad.glow_enabled self._glow_intensity = lightpad.glow_intensity + unique_id = f"{self._lightpad.lpid}.glow" + self._attr_unique_id = unique_id self._red = lightpad.glow_color["red"] self._green = lightpad.glow_color["green"] self._blue = lightpad.glow_color["blue"] + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Plum", + model="Glow Ring", + name=self._attr_name, + ) async def async_added_to_hass(self) -> None: """Subscribe to configchange events.""" @@ -159,13 +161,12 @@ def configchange_event(self, event): """Handle Configuration change event.""" config = event["changes"] - self._state = config["glowEnabled"] + self._attr_is_on = config["glowEnabled"] self._glow_intensity = config["glowIntensity"] self._red = config["glowColor"]["red"] self._green = config["glowColor"]["green"] self._blue = config["glowColor"]["blue"] - self.schedule_update_ha_state() @property @@ -173,46 +174,11 @@ def hs_color(self): """Return the hue and saturation color value [float, float].""" return color_util.color_RGB_to_hs(self._red, self._green, self._blue) - @property - def unique_id(self): - """Combine LightPad ID with .glow to guarantee it is unique.""" - return f"{self._lightpad.lpid}.glow" - - @property - def name(self): - """Return the name of the switch if any.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Plum", - model="Glow Ring", - name=self.name, - ) - @property def brightness(self) -> int: """Return the brightness of this switch between 0..255.""" return min(max(int(round(self._glow_intensity * 255, 0)), 0), 255) - @property - def glow_intensity(self): - """Brightness in float form.""" - return self._glow_intensity - - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._state - - @property - def icon(self): - """Return the crop-portrait icon representing the glow ring.""" - return "mdi:crop-portrait" - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: From 2565f153cdcd1e72c84cafd8fa3463b33c21d183 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 6 Sep 2023 17:26:14 -0600 Subject: [PATCH 179/640] Bump `aiorecollect` to 2023.09.0 (#99780) --- homeassistant/components/recollect_waste/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/recollect_waste/manifest.json b/homeassistant/components/recollect_waste/manifest.json index dc31adddb782aa..e1ad3f989501dc 100644 --- a/homeassistant/components/recollect_waste/manifest.json +++ b/homeassistant/components/recollect_waste/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["aiorecollect"], - "requirements": ["aiorecollect==1.0.8"] + "requirements": ["aiorecollect==2023.09.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b959d43886fa4d..a3c8d80ce7de67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -328,7 +328,7 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c9dc2f9184fc19..7463331ac3f629 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -303,7 +303,7 @@ aiopyarr==23.4.0 aioqsw==0.3.4 # homeassistant.components.recollect_waste -aiorecollect==1.0.8 +aiorecollect==2023.09.0 # homeassistant.components.ridwell aioridwell==2023.07.0 From 0c7e0f5cd92f68b7ef772e11f832f68392a2fe46 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 6 Sep 2023 20:01:22 -0500 Subject: [PATCH 180/640] Bump sense_energy to 0.12.1 (#99763) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 324279db7d9e66..d39d530ecccb73 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.12.0"] + "requirements": ["sense_energy==0.12.1"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8c20db2e422466..8a89d6d8531dc5 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.12.0"] + "requirements": ["sense-energy==0.12.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index a3c8d80ce7de67..792a96656ed887 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2371,10 +2371,10 @@ securetar==2023.3.0 sendgrid==6.8.2 # homeassistant.components.sense -sense-energy==0.12.0 +sense-energy==0.12.1 # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +sense_energy==0.12.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7463331ac3f629..d4cf7e3f79db78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1734,10 +1734,10 @@ screenlogicpy==0.8.2 securetar==2023.3.0 # homeassistant.components.sense -sense-energy==0.12.0 +sense-energy==0.12.1 # homeassistant.components.emulated_kasa -sense_energy==0.12.0 +sense_energy==0.12.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.0 From e1f4a3fa9fea4f8acdda8fa2b5c25ee1a47d1554 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Thu, 7 Sep 2023 04:59:04 +0000 Subject: [PATCH 181/640] Add energy meter sensors for Shelly Pro EM (#99747) * Add support for Pro EM * Improve get_rpc_channel_name() * Revert an unintended change * Add tests --- homeassistant/components/shelly/sensor.py | 78 +++++++++++++++++++++++ homeassistant/components/shelly/utils.py | 3 + tests/components/shelly/conftest.py | 4 ++ tests/components/shelly/test_sensor.py | 40 ++++++++++++ 4 files changed, 125 insertions(+) diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index abcca888005425..99ccd9ab2ff289 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -363,6 +363,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.POWER, state_class=SensorStateClass.MEASUREMENT, ), + "power_em1": RpcSensorDescription( + key="em1", + sub_key="act_power", + name="Power", + native_unit_of_measurement=UnitOfPower.WATT, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "power_pm1": RpcSensorDescription( key="pm1", sub_key="apower", @@ -427,6 +435,14 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "aprt_power_em1": RpcSensorDescription( + key="em1", + sub_key="aprt_power", + name="Apparent power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + device_class=SensorDeviceClass.APPARENT_POWER, + state_class=SensorStateClass.MEASUREMENT, + ), "total_aprt_power": RpcSensorDescription( key="em", sub_key="total_aprt_power", @@ -435,6 +451,13 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.APPARENT_POWER, state_class=SensorStateClass.MEASUREMENT, ), + "pf_em1": RpcSensorDescription( + key="em1", + sub_key="pf", + name="Power factor", + device_class=SensorDeviceClass.POWER_FACTOR, + state_class=SensorStateClass.MEASUREMENT, + ), "a_pf": RpcSensorDescription( key="em", sub_key="a_pf", @@ -467,6 +490,17 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "voltage_em1": RpcSensorDescription( + key="em1", + sub_key="voltage", + name="Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + value=lambda status, _: None if status is None else float(status), + suggested_display_precision=1, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "voltage_pm1": RpcSensorDescription( key="pm1", sub_key="voltage", @@ -515,6 +549,16 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "current_em1": RpcSensorDescription( + key="em1", + sub_key="current", + name="Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + value=lambda status, _: None if status is None else float(status), + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "current_pm1": RpcSensorDescription( key="pm1", sub_key="current", @@ -605,6 +649,18 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_energy", + name="Total active energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_energy", @@ -652,6 +708,18 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): device_class=SensorDeviceClass.ENERGY, state_class=SensorStateClass.TOTAL_INCREASING, ), + "total_act_ret_energy": RpcSensorDescription( + key="em1data", + sub_key="total_act_ret_energy", + name="Total active returned energy", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + value=lambda status, _: float(status), + suggested_display_precision=2, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + entity_registry_enabled_default=False, + ), "a_total_act_ret_energy": RpcSensorDescription( key="emdata", sub_key="a_total_act_ret_energy", @@ -698,6 +766,16 @@ class RestSensorDescription(RestEntityDescription, SensorEntityDescription): state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, ), + "freq_em1": RpcSensorDescription( + key="em1", + sub_key="freq", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + suggested_display_precision=0, + device_class=SensorDeviceClass.FREQUENCY, + state_class=SensorStateClass.MEASUREMENT, + entity_registry_enabled_default=False, + ), "freq_pm1": RpcSensorDescription( key="pm1", sub_key="freq", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index a66b77ed94b61d..e78b44db15eb25 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -288,6 +288,7 @@ def get_model_name(info: dict[str, Any]) -> str: def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") + key = key.replace("em1data", "em1") if device.config.get("switch:0"): key = key.replace("input", "switch") device_name = device.name @@ -298,6 +299,8 @@ def get_rpc_channel_name(device: RpcDevice, key: str) -> str: if entity_name is None: if key.startswith(("input:", "light:", "switch:")): return f"{device_name} {key.replace(':', '_')}" + if key.startswith("em1"): + return f"{device_name} EM{key.split(':')[-1]}" return device_name return entity_name diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index 797673265a62a1..e72604260f5cb3 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -202,6 +202,10 @@ def mock_light_set_state( "devicepower:0": {"external": {"present": True}}, "temperature:0": {"tC": 22.9}, "illuminance:0": {"lux": 345}, + "em1:0": {"act_power": 85.3}, + "em1:1": {"act_power": 123.3}, + "em1data:0": {"total_act_energy": 123456.4}, + "em1data:1": {"total_act_energy": 987654.3}, "sys": { "available_updates": { "beta": {"version": "some_beta_version"}, diff --git a/tests/components/shelly/test_sensor.py b/tests/components/shelly/test_sensor.py index 892d06ad626214..a738113f18ff86 100644 --- a/tests/components/shelly/test_sensor.py +++ b/tests/components/shelly/test_sensor.py @@ -408,3 +408,43 @@ async def test_rpc_restored_sleeping_sensor_no_last_state( await hass.async_block_till_done() assert hass.states.get(entity_id).state == "22.9" + + +async def test_rpc_em1_sensors( + hass: HomeAssistant, mock_rpc_device, entity_registry_enabled_by_default: None +) -> None: + """Test RPC sensors for EM1 component.""" + registry = async_get(hass) + await init_integration(hass, 2) + + state = hass.states.get("sensor.test_name_em0_power") + assert state + assert state.state == "85.3" + + entry = registry.async_get("sensor.test_name_em0_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:0-power_em1" + + state = hass.states.get("sensor.test_name_em1_power") + assert state + assert state.state == "123.3" + + entry = registry.async_get("sensor.test_name_em1_power") + assert entry + assert entry.unique_id == "123456789ABC-em1:1-power_em1" + + state = hass.states.get("sensor.test_name_em0_total_active_energy") + assert state + assert state.state == "123.4564" + + entry = registry.async_get("sensor.test_name_em0_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:0-total_act_energy" + + state = hass.states.get("sensor.test_name_em1_total_active_energy") + assert state + assert state.state == "987.6543" + + entry = registry.async_get("sensor.test_name_em1_total_active_energy") + assert entry + assert entry.unique_id == "123456789ABC-em1data:1-total_act_energy" From 1a22ab77e1af859a27348df3a87fc2a8e226258c Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Sep 2023 10:28:08 +0200 Subject: [PATCH 182/640] Fix Freebox disk free space sensor (#99757) * Fix Freebox disk free space sensor * Add initial value assert to check results --- .coveragerc | 1 - homeassistant/components/freebox/router.py | 7 +++- homeassistant/components/freebox/sensor.py | 13 ++++--- tests/components/freebox/common.py | 27 +++++++++++++ tests/components/freebox/test_sensor.py | 45 ++++++++++++++++++++++ 5 files changed, 85 insertions(+), 8 deletions(-) create mode 100644 tests/components/freebox/common.py create mode 100644 tests/components/freebox/test_sensor.py diff --git a/.coveragerc b/.coveragerc index d28878d8861fd4..c72400392b788e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -417,7 +417,6 @@ omit = homeassistant/components/freebox/device_tracker.py homeassistant/components/freebox/home_base.py homeassistant/components/freebox/router.py - homeassistant/components/freebox/sensor.py homeassistant/components/freebox/switch.py homeassistant/components/fritz/common.py homeassistant/components/fritz/device_tracker.py diff --git a/homeassistant/components/freebox/router.py b/homeassistant/components/freebox/router.py index 7c83e98054051f..cd5862a2f802be 100644 --- a/homeassistant/components/freebox/router.py +++ b/homeassistant/components/freebox/router.py @@ -156,7 +156,12 @@ async def _update_disks_sensors(self) -> None: fbx_disks: list[dict[str, Any]] = await self._api.storage.get_disks() or [] for fbx_disk in fbx_disks: - self.disks[fbx_disk["id"]] = fbx_disk + disk: dict[str, Any] = {**fbx_disk} + disk_part: dict[int, dict[str, Any]] = {} + for fbx_disk_part in fbx_disk["partitions"]: + disk_part[fbx_disk_part["id"]] = fbx_disk_part + disk["partitions"] = disk_part + self.disks[fbx_disk["id"]] = disk async def _update_raids_sensors(self) -> None: """Update Freebox raids.""" diff --git a/homeassistant/components/freebox/sensor.py b/homeassistant/components/freebox/sensor.py index 901bfc6319985d..4e7c3910c54fba 100644 --- a/homeassistant/components/freebox/sensor.py +++ b/homeassistant/components/freebox/sensor.py @@ -95,7 +95,7 @@ async def async_setup_entry( entities.extend( FreeboxDiskSensor(router, disk, partition, description) for disk in router.disks.values() - for partition in disk["partitions"] + for partition in disk["partitions"].values() for description in DISK_PARTITION_SENSORS ) @@ -197,7 +197,8 @@ def __init__( ) -> None: """Initialize a Freebox disk sensor.""" super().__init__(router, description) - self._partition = partition + self._disk_id = disk["id"] + self._partition_id = partition["id"] self._attr_name = f"{partition['label']} {description.name}" self._attr_unique_id = ( f"{router.mac} {description.key} {disk['id']} {partition['id']}" @@ -218,10 +219,10 @@ def __init__( def async_update_state(self) -> None: """Update the Freebox disk sensor.""" value = None - if self._partition.get("total_bytes"): - value = round( - self._partition["free_bytes"] * 100 / self._partition["total_bytes"], 2 - ) + disk: dict[str, Any] = self._router.disks[self._disk_id] + partition: dict[str, Any] = disk["partitions"][self._partition_id] + if partition.get("total_bytes"): + value = round(partition["free_bytes"] * 100 / partition["total_bytes"], 2) self._attr_native_value = value diff --git a/tests/components/freebox/common.py b/tests/components/freebox/common.py new file mode 100644 index 00000000000000..9f7dfd8f92a029 --- /dev/null +++ b/tests/components/freebox/common.py @@ -0,0 +1,27 @@ +"""Common methods used across tests for Freebox.""" +from unittest.mock import patch + +from homeassistant.components.freebox.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from .const import MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry + + +async def setup_platform(hass: HomeAssistant, platform: str) -> MockConfigEntry: + """Set up the Freebox platform.""" + mock_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + unique_id=MOCK_HOST, + ) + mock_entry.add_to_hass(hass) + + with patch("homeassistant.components.freebox.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return mock_entry diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py new file mode 100644 index 00000000000000..2ebcf8baa04239 --- /dev/null +++ b/tests/components/freebox/test_sensor.py @@ -0,0 +1,45 @@ +"""Tests for the Freebox sensors.""" +from copy import deepcopy +from unittest.mock import Mock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.freebox import SCAN_INTERVAL +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant + +from .common import setup_platform +from .const import DATA_STORAGE_GET_DISKS + +from tests.common import async_fire_time_changed + + +async def test_disk( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test disk sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + # Initial state + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["total_bytes"] + == 1960000000000 + ) + + assert ( + router().storage.get_disks.return_value[2]["partitions"][0]["free_bytes"] + == 1730000000000 + ) + + assert hass.states.get("sensor.freebox_free_space").state == "88.27" + + # Simulate a changed storage size + data_storage_get_disks_changed = deepcopy(DATA_STORAGE_GET_DISKS) + data_storage_get_disks_changed[2]["partitions"][0]["free_bytes"] = 880000000000 + router().storage.get_disks.return_value = data_storage_get_disks_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_free_space").state == "44.9" From d2f9270bc9155a6ad5763c1bc0eaf543c9e450b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 7 Sep 2023 10:36:49 +0200 Subject: [PATCH 183/640] Add my self as codeowner for airthings_ble (#99799) Update airthings_ble codeowner --- CODEOWNERS | 4 ++-- homeassistant/components/airthings_ble/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 58812a0baf23a2..0cb1bef619189c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -47,8 +47,8 @@ build.json @home-assistant/supervisor /tests/components/airq/ @Sibgatulin @dl2080 /homeassistant/components/airthings/ @danielhiversen /tests/components/airthings/ @danielhiversen -/homeassistant/components/airthings_ble/ @vincegio -/tests/components/airthings_ble/ @vincegio +/homeassistant/components/airthings_ble/ @vincegio @LaStrada +/tests/components/airthings_ble/ @vincegio @LaStrada /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya diff --git a/homeassistant/components/airthings_ble/manifest.json b/homeassistant/components/airthings_ble/manifest.json index ef9ad3a802e97d..cb7114ff8ff715 100644 --- a/homeassistant/components/airthings_ble/manifest.json +++ b/homeassistant/components/airthings_ble/manifest.json @@ -19,7 +19,7 @@ "service_uuid": "b42e3882-ade7-11e4-89d3-123b93f75cba" } ], - "codeowners": ["@vincegio"], + "codeowners": ["@vincegio", "@LaStrada"], "config_flow": true, "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/airthings_ble", From 9351e79dcb8f515041cee731cbf4641dfac41eda Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Arda=20=C5=9EEREMET?= Date: Thu, 7 Sep 2023 11:53:59 +0300 Subject: [PATCH 184/640] Bump ProgettiHWSW to 0.1.3 (#92668) * Update manifest.json * Update requirements_test_all.txt * Update requirements_all.txt * Updated dependencies file. * Update manifest.json with correct naming convention. Co-authored-by: Martin Hjelmare * Updated requirements. --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/progettihwsw/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/progettihwsw/manifest.json b/homeassistant/components/progettihwsw/manifest.json index 6cad66e136040f..d5c91fcea10709 100644 --- a/homeassistant/components/progettihwsw/manifest.json +++ b/homeassistant/components/progettihwsw/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/progettihwsw", "iot_class": "local_polling", "loggers": ["ProgettiHWSW"], - "requirements": ["ProgettiHWSW==0.1.1"] + "requirements": ["ProgettiHWSW==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 792a96656ed887..c2e9c2e9569f29 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -49,7 +49,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.bluetooth_tracker # PyBluez==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d4cf7e3f79db78..0ce3babc3ff857 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -45,7 +45,7 @@ Pillow==10.0.0 PlexAPI==4.13.2 # homeassistant.components.progettihwsw -ProgettiHWSW==0.1.1 +ProgettiHWSW==0.1.3 # homeassistant.components.cast PyChromecast==13.0.7 From e5210c582398f77ff48453716571e34ae5309f5c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 12:00:19 +0200 Subject: [PATCH 185/640] Always set severity level flag on render_template error events (#99804) --- homeassistant/components/websocket_api/commands.py | 4 +++- tests/components/websocket_api/test_commands.py | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 7772bef66f9ae5..a05f2aa8e3fc0f 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -565,7 +565,9 @@ def _template_listener( if not report_errors: return connection.send_message( - messages.event_message(msg["id"], {"error": str(result)}) + messages.event_message( + msg["id"], {"error": str(result), "level": "ERROR"} + ) ) return diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 96e79a81716916..70f08477a729d8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1512,7 +1512,10 @@ async def test_render_template_with_delayed_error( assert msg["id"] == 5 assert msg["type"] == "event" event = msg["event"] - assert event == {"error": "UndefinedError: 'explode' is undefined"} + assert event == { + "error": "UndefinedError: 'explode' is undefined", + "level": "ERROR", + } assert "Template variable error" not in caplog.text assert "Template variable warning" not in caplog.text From 0cc2c27115bd951a07cab41c6183a93e464de014 Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Thu, 7 Sep 2023 13:16:31 +0300 Subject: [PATCH 186/640] Add strict typing to islamic prayer times (#99585) * Add strict typing to islamic prayer times * fix mypy errors --- .strict-typing | 1 + .../components/islamic_prayer_times/coordinator.py | 7 ++++--- mypy.ini | 10 ++++++++++ 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.strict-typing b/.strict-typing index 30d20a6fc545e5..ee97deb9af47ea 100644 --- a/.strict-typing +++ b/.strict-typing @@ -188,6 +188,7 @@ homeassistant.components.input_select.* homeassistant.components.integration.* homeassistant.components.ipp.* homeassistant.components.iqvia.* +homeassistant.components.islamic_prayer_times.* homeassistant.components.isy994.* homeassistant.components.jellyfin.* homeassistant.components.jewish_calendar.* diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 1a8b0bf70364b5..30362c763daaaf 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import logging +from typing import Any, cast from prayer_times_calculator import PrayerTimesCalculator, exceptions from requests.exceptions import ConnectionError as ConnError @@ -37,7 +38,7 @@ def calc_method(self) -> str: """Return the calculation method.""" return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) - def get_new_prayer_times(self) -> dict[str, str]: + def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( latitude=self.hass.config.latitude, @@ -45,7 +46,7 @@ def get_new_prayer_times(self) -> dict[str, str]: calculation_method=self.calc_method, date=str(dt_util.now().date()), ) - return calc.fetch_prayer_times() + return cast(dict[str, Any], calc.fetch_prayer_times()) @callback def async_schedule_future_update(self, midnight_dt: datetime) -> None: @@ -98,7 +99,7 @@ def async_schedule_future_update(self, midnight_dt: datetime) -> None: self.hass, self.async_request_update, next_update_at ) - async def async_request_update(self, *_) -> None: + async def async_request_update(self, _: datetime) -> None: """Request update from coordinator.""" await self.async_request_refresh() diff --git a/mypy.ini b/mypy.ini index 1c3fc1a52ed9e3..eda6f35cdfa604 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1642,6 +1642,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.islamic_prayer_times.*] +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.isy994.*] check_untyped_defs = true disallow_incomplete_defs = true From f1ae523ff2f7950d03ff98d0f5e0ca3201e44bda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 05:17:04 -0500 Subject: [PATCH 187/640] Bump pyenphase to 1.9.3 (#99787) * Bump pyenphase to 1.9.2 changelog: https://github.com/pyenphase/pyenphase/compare/v1.9.1...v1.9.2 Handle the case where the user has manually specified a password for local auth with firmware < 7.x but its incorrect. The integration previously accepted any wrong password and would reduce functionality down to what works without a password. We now preserve that behavior to avoid breaking existing installs. * bump --- 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 a45f4f01e49a9f..d3a36b16b60b63 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.9.1"], + "requirements": ["pyenphase==1.9.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c2e9c2e9569f29..b85338c658e4f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1673,7 +1673,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.1 +pyenphase==1.9.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0ce3babc3ff857..a3680b4cfd46d2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1240,7 +1240,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.1 +pyenphase==1.9.3 # homeassistant.components.everlights pyeverlights==0.1.0 From e8dfa7e2c86eaa453153fc191bd5bd29147616c2 Mon Sep 17 00:00:00 2001 From: swamplynx Date: Thu, 7 Sep 2023 06:17:38 -0400 Subject: [PATCH 188/640] Bump pylutron-caseta to v0.18.2 (#99789) * Bump pylutron-caseta to v0.18.2 Minor bump to pylutron-caseta requirement to support wall mounted occupancy sensor device type in latest RA3 firmware. * Update requirements_all.txt for pylutron-caseta 0.18.2 * Update requirements_test_all.txt for pylutron-caseta 0.18.2 --- 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 feab9744df0151..bf6ed32c6680ff 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.1"], + "requirements": ["pylutron-caseta==0.18.2"], "zeroconf": [ { "type": "_lutron._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index b85338c658e4f0..021e7da2c9111e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1823,7 +1823,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.lutron pylutron==0.2.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a3680b4cfd46d2..352737e583fbea 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1354,7 +1354,7 @@ pylitejet==0.5.0 pylitterbot==2023.4.5 # homeassistant.components.lutron_caseta -pylutron-caseta==0.18.1 +pylutron-caseta==0.18.2 # homeassistant.components.mailgun pymailgunner==1.4 From 7c3605c82e3bad803424b413707fb7cd4eef4ac7 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 7 Sep 2023 12:22:46 +0200 Subject: [PATCH 189/640] Use config entry ID as unique ID and remove dependency to getmac in Minecraft Server (#97837) * Use config entry ID as unique ID * Add entry migration to v2 and and remove helper module * Remove unneeded strings * Add asserts for config, device and entity entries and improve comments * Add debug log for config entry migration * Reset config entry unique ID and use config entry ID instead * Remove unnecessary unique ID debug log * Revert usage of constants for tranlation keys and use dash as delimiter for entity unique id suffix * Revert "Revert usage of constants for tranlation keys and use dash as delimiter for entity unique id suffix" This reverts commit 07de334606054097e914404da04950e952bef6d2. * Remove unused logger in entity module --- .../components/minecraft_server/__init__.py | 150 ++++++++++++++++-- .../minecraft_server/binary_sensor.py | 6 +- .../minecraft_server/config_flow.py | 64 +------- .../components/minecraft_server/const.py | 8 - .../components/minecraft_server/entity.py | 5 +- .../components/minecraft_server/helpers.py | 35 ---- .../components/minecraft_server/manifest.json | 2 +- .../components/minecraft_server/sensor.py | 24 ++- .../components/minecraft_server/strings.json | 6 +- requirements_all.txt | 1 - requirements_test_all.txt | 1 - .../minecraft_server/test_config_flow.py | 45 +----- 12 files changed, 163 insertions(+), 184 deletions(-) delete mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index cf0d96af8d25ef..a13196dffc6e2e 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -7,16 +7,25 @@ import logging from typing import Any +import aiodns from mcstatus.server import JavaServer from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +import homeassistant.helpers.device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send +import homeassistant.helpers.entity_registry as er from homeassistant.helpers.event import async_track_time_interval -from . import helpers -from .const import DOMAIN, SCAN_INTERVAL, SIGNAL_NAME_PREFIX +from .const import ( + DOMAIN, + KEY_LATENCY, + KEY_MOTD, + SCAN_INTERVAL, + SIGNAL_NAME_PREFIX, + SRV_RECORD_PREFIX, +) PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -28,15 +37,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: domain_data = hass.data.setdefault(DOMAIN, {}) # Create and store server instance. - assert entry.unique_id - unique_id = entry.unique_id + config_entry_id = entry.entry_id _LOGGER.debug( "Creating server instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, unique_id, entry.data) - domain_data[unique_id] = server + server = MinecraftServer(hass, config_entry_id, entry.data) + domain_data[config_entry_id] = server await server.async_update() server.start_periodic_update() @@ -48,8 +56,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" - unique_id = config_entry.unique_id - server = hass.data[DOMAIN][unique_id] + config_entry_id = config_entry.entry_id + server = hass.data[DOMAIN][config_entry_id] # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -58,11 +66,110 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Clean up. server.stop_periodic_update() - hass.data[DOMAIN].pop(unique_id) + hass.data[DOMAIN].pop(config_entry_id) return unload_ok +async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Migrate old config entry to a new format.""" + _LOGGER.debug("Migrating from version %s", config_entry.version) + + # 1 --> 2: Use config entry ID as base for unique IDs. + if config_entry.version == 1: + assert config_entry.unique_id + assert config_entry.entry_id + old_unique_id = config_entry.unique_id + config_entry_id = config_entry.entry_id + + # Migrate config entry. + _LOGGER.debug("Migrating config entry. Resetting unique ID: %s", old_unique_id) + config_entry.unique_id = None + config_entry.version = 2 + hass.config_entries.async_update_entry(config_entry) + + # Migrate device. + await _async_migrate_device_identifiers(hass, config_entry, old_unique_id) + + # Migrate entities. + await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) + + _LOGGER.info("Migration to version %s successful", config_entry.version) + + return True + + +async def _async_migrate_device_identifiers( + hass: HomeAssistant, config_entry: ConfigEntry, old_unique_id: str | None +) -> None: + """Migrate the device identifiers to the new format.""" + device_registry = dr.async_get(hass) + device_entry_found = False + for device_entry in dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ): + assert device_entry + for identifier in device_entry.identifiers: + if identifier[1] == old_unique_id: + # Device found in registry. Update identifiers. + new_identifiers = { + ( + DOMAIN, + config_entry.entry_id, + ) + } + _LOGGER.debug( + "Migrating device identifiers from %s to %s", + device_entry.identifiers, + new_identifiers, + ) + device_registry.async_update_device( + device_id=device_entry.id, new_identifiers=new_identifiers + ) + # Device entry found. Leave inner for loop. + device_entry_found = True + break + + # Leave outer for loop if device entry is already found. + if device_entry_found: + break + + +@callback +def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: + """Migrate the unique ID of an entity to the new format.""" + assert entity_entry + + # Different variants of unique IDs are available in version 1: + # 1) SRV record: '-srv-' + # 2) Host & port: '--' + # 3) IP address & port: '--' + unique_id_pieces = entity_entry.unique_id.split("-") + entity_type = unique_id_pieces[2] + + # Handle bug in version 1: Entity type names were used instead of + # keys (e.g. "Protocol Version" instead of "protocol_version"). + new_entity_type = entity_type.lower() + new_entity_type = new_entity_type.replace(" ", "_") + + # Special case 'MOTD': Name and key differs. + if new_entity_type == "world_message": + new_entity_type = KEY_MOTD + + # Special case 'latency_time': Renamed to 'latency'. + if new_entity_type == "latency_time": + new_entity_type = KEY_LATENCY + + new_unique_id = f"{entity_entry.config_entry_id}-{new_entity_type}" + _LOGGER.debug( + "Migrating entity unique ID from %s to %s", + entity_entry.unique_id, + new_unique_id, + ) + + return {"new_unique_id": new_unique_id} + + @dataclass class MinecraftServerData: """Representation of Minecraft server data.""" @@ -122,7 +229,7 @@ async def async_check_connection(self) -> None: # Check if host is a valid SRV record, if not already done. if not self.srv_record_checked: self.srv_record_checked = True - srv_record = await helpers.async_check_srv_record(self._hass, self.host) + srv_record = await self._async_check_srv_record(self.host) if srv_record is not None: _LOGGER.debug( "'%s' is a valid Minecraft SRV record ('%s:%s')", @@ -152,6 +259,27 @@ async def async_check_connection(self) -> None: ) self.online = False + async def _async_check_srv_record(self, host: str) -> dict[str, Any] | None: + """Check if the given host is a valid Minecraft SRV record.""" + srv_record = None + srv_query = None + + try: + srv_query = await aiodns.DNSResolver().query( + host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" + ) + except aiodns.error.DNSError: + # 'host' is not a SRV record. + pass + else: + # 'host' is a valid SRV record, extract the data. + srv_record = { + CONF_HOST: srv_query[0].host, + CONF_PORT: srv_query[0].port, + } + + return srv_record + async def async_update(self, now: datetime | None = None) -> None: """Get server data from 3rd party library and update properties.""" # Check connection status. diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 3589bfab3e2932..3721a50b1ded3a 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -8,7 +8,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import MinecraftServer -from .const import DOMAIN, ICON_STATUS, KEY_STATUS, NAME_STATUS +from .const import DOMAIN, ICON_STATUS, KEY_STATUS from .entity import MinecraftServerEntity @@ -18,7 +18,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] + server = hass.data[DOMAIN][config_entry.entry_id] # Create entities list. entities = [MinecraftServerStatusBinarySensor(server)] @@ -36,7 +36,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize status binary sensor.""" super().__init__( server=server, - type_name=NAME_STATUS, + entity_type=KEY_STATUS, icon=ICON_STATUS, device_class=BinarySensorDeviceClass.CONNECTIVITY, ) diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index c8429284cd8cb5..cdb345df55c5c4 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,23 +1,20 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress -from functools import partial -import ipaddress -import getmac import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer, helpers +from . import MinecraftServer from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" - VERSION = 1 + VERSION = 2 async def async_step_user(self, user_input=None) -> FlowResult: """Handle the initial step.""" @@ -26,10 +23,13 @@ async def async_step_user(self, user_input=None) -> FlowResult: if user_input is not None: host = None port = DEFAULT_PORT + title = user_input[CONF_HOST] + # Split address at last occurrence of ':'. address_left, separator, address_right = user_input[CONF_HOST].rpartition( ":" ) + # If no separator is found, 'rpartition' returns ('', '', original_string). if separator == "": host = address_right @@ -41,32 +41,8 @@ async def async_step_user(self, user_input=None) -> FlowResult: # Remove '[' and ']' in case of an IPv6 address. host = host.strip("[]") - # Check if 'host' is a valid IP address and if so, get the MAC address. - ip_address = None - mac_address = None - try: - ip_address = ipaddress.ip_address(host) - except ValueError: - # Host is not a valid IP address. - # Continue with host and port. - pass - else: - # Host is a valid IP address. - if ip_address.version == 4: - # Address type is IPv4. - params = {"ip": host} - else: - # Address type is IPv6. - params = {"ip6": host} - mac_address = await self.hass.async_add_executor_job( - partial(getmac.get_mac_address, **params) - ) - - # Validate IP address (MAC address must be available). - if ip_address is not None and mac_address is None: - errors["base"] = "invalid_ip" # Validate port configuration (limit to user and dynamic port range). - elif (port < 1024) or (port > 65535): + if (port < 1024) or (port > 65535): errors["base"] = "invalid_port" # Validate host and port by checking the server connection. else: @@ -82,34 +58,6 @@ async def async_step_user(self, user_input=None) -> FlowResult: # Host or port invalid or server not reachable. errors["base"] = "cannot_connect" else: - # Build unique_id and config entry title. - unique_id = "" - title = f"{host}:{port}" - if ip_address is not None: - # Since IP addresses can change and therefore are not allowed - # in a unique_id, fall back to the MAC address and port (to - # support servers with same MAC address but different ports). - unique_id = f"{mac_address}-{port}" - if ip_address.version == 6: - title = f"[{host}]:{port}" - else: - # Check if 'host' is a valid SRV record. - srv_record = await helpers.async_check_srv_record( - self.hass, host - ) - if srv_record is not None: - # Use only SRV host name in unique_id (does not change). - unique_id = f"{host}-srv" - title = host - else: - # Use host name and port in unique_id (to support servers - # with same host name but different ports). - unique_id = f"{host}-{port}" - - # Abort in case the host was already configured before. - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured() - # Configuration data are available and no error was detected, # create configuration entry. return self.async_create_entry(title=title, data=config_data) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 72a891138c4b39..5b59913c790c03 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -26,14 +26,6 @@ MANUFACTURER = "Mojang AB" -NAME_LATENCY = "Latency Time" -NAME_PLAYERS_MAX = "Players Max" -NAME_PLAYERS_ONLINE = "Players Online" -NAME_PROTOCOL_VERSION = "Protocol Version" -NAME_STATUS = "Status" -NAME_VERSION = "Version" -NAME_MOTD = "World Message" - SCAN_INTERVAL = 60 SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 63d68d0aa77891..9048cb94004845 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,5 +1,6 @@ """Base entity for the Minecraft Server integration.""" + from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -18,14 +19,14 @@ class MinecraftServerEntity(Entity): def __init__( self, server: MinecraftServer, - type_name: str, + entity_type: str, icon: str, device_class: str | None, ) -> None: """Initialize base entity.""" self._server = server self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{type_name}" + self._attr_unique_id = f"{self._server.unique_id}-{entity_type}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, self._server.unique_id)}, manufacturer=MANUFACTURER, diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py deleted file mode 100644 index d4a49d96f839f1..00000000000000 --- a/homeassistant/components/minecraft_server/helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -"""Helper functions for the Minecraft Server integration.""" -from __future__ import annotations - -from typing import Any - -import aiodns - -from homeassistant.const import CONF_HOST, CONF_PORT -from homeassistant.core import HomeAssistant - -from .const import SRV_RECORD_PREFIX - - -async def async_check_srv_record( - hass: HomeAssistant, host: str -) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - # Check if 'host' is a valid SRV record. - return_value = None - srv_records = None - try: - srv_records = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a SRV record. - pass - else: - # 'host' is a valid SRV record, extract the data. - return_value = { - CONF_HOST: srv_records[0].host, - CONF_PORT: srv_records[0].port, - } - - return return_value diff --git a/homeassistant/components/minecraft_server/manifest.json b/homeassistant/components/minecraft_server/manifest.json index 27019cb80a8bf4..758f22b1e9a795 100644 --- a/homeassistant/components/minecraft_server/manifest.json +++ b/homeassistant/components/minecraft_server/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["dnspython", "mcstatus"], "quality_scale": "silver", - "requirements": ["aiodns==3.0.0", "getmac==0.8.2", "mcstatus==11.0.0"] + "requirements": ["aiodns==3.0.0", "mcstatus==11.0.0"] } diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 74422675718ac9..e17050310a8d3c 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -23,12 +23,6 @@ KEY_PLAYERS_ONLINE, KEY_PROTOCOL_VERSION, KEY_VERSION, - NAME_LATENCY, - NAME_MOTD, - NAME_PLAYERS_MAX, - NAME_PLAYERS_ONLINE, - NAME_PROTOCOL_VERSION, - NAME_VERSION, UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) @@ -41,7 +35,7 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - server = hass.data[DOMAIN][config_entry.unique_id] + server = hass.data[DOMAIN][config_entry.entry_id] # Create entities list. entities = [ @@ -63,13 +57,13 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): def __init__( self, server: MinecraftServer, - type_name: str, + entity_type: str, icon: str, unit: str | None = None, device_class: str | None = None, ) -> None: """Initialize sensor base entity.""" - super().__init__(server, type_name, icon, device_class) + super().__init__(server, entity_type, icon, device_class) self._attr_native_unit_of_measurement = unit @property @@ -85,7 +79,7 @@ class MinecraftServerVersionSensor(MinecraftServerSensorEntity): def __init__(self, server: MinecraftServer) -> None: """Initialize version sensor.""" - super().__init__(server=server, type_name=NAME_VERSION, icon=ICON_VERSION) + super().__init__(server=server, entity_type=KEY_VERSION, icon=ICON_VERSION) async def async_update(self) -> None: """Update version.""" @@ -101,7 +95,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize protocol version sensor.""" super().__init__( server=server, - type_name=NAME_PROTOCOL_VERSION, + entity_type=KEY_PROTOCOL_VERSION, icon=ICON_PROTOCOL_VERSION, ) @@ -119,7 +113,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize latency sensor.""" super().__init__( server=server, - type_name=NAME_LATENCY, + entity_type=KEY_LATENCY, icon=ICON_LATENCY, unit=UnitOfTime.MILLISECONDS, ) @@ -138,7 +132,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize online players sensor.""" super().__init__( server=server, - type_name=NAME_PLAYERS_ONLINE, + entity_type=KEY_PLAYERS_ONLINE, icon=ICON_PLAYERS_ONLINE, unit=UNIT_PLAYERS_ONLINE, ) @@ -165,7 +159,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize maximum number of players sensor.""" super().__init__( server=server, - type_name=NAME_PLAYERS_MAX, + entity_type=KEY_PLAYERS_MAX, icon=ICON_PLAYERS_MAX, unit=UNIT_PLAYERS_MAX, ) @@ -184,7 +178,7 @@ def __init__(self, server: MinecraftServer) -> None: """Initialize MOTD sensor.""" super().__init__( server=server, - type_name=NAME_MOTD, + entity_type=KEY_MOTD, icon=ICON_MOTD, ) diff --git a/homeassistant/components/minecraft_server/strings.json b/homeassistant/components/minecraft_server/strings.json index b4d68bc611744d..b64c96f580b331 100644 --- a/homeassistant/components/minecraft_server/strings.json +++ b/homeassistant/components/minecraft_server/strings.json @@ -12,11 +12,7 @@ }, "error": { "invalid_port": "Port must be in range from 1024 to 65535. Please correct it and try again.", - "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server.", - "invalid_ip": "IP address is invalid (MAC address could not be determined). Please correct it and try again." - }, - "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + "cannot_connect": "Failed to connect to server. Please check the host and port and try again. Also ensure that you are running at least Minecraft version 1.7 on your server." } }, "entity": { diff --git a/requirements_all.txt b/requirements_all.txt index 021e7da2c9111e..5ba5612db1204e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -865,7 +865,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 352737e583fbea..62f6bdd23348f2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -681,7 +681,6 @@ georss-qld-bushfire-alert-client==0.5 # homeassistant.components.dlna_dmr # homeassistant.components.kef -# homeassistant.components.minecraft_server # homeassistant.components.nmap_tracker # homeassistant.components.samsungtv # homeassistant.components.upnp diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index 3a201f15bf3121..d9e7d46a88c166 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -11,12 +11,10 @@ DOMAIN, ) from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.const import CONF_HOST, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry - class QueryMock: """Mock for result of aiodns.DNSResolver.query.""" @@ -82,47 +80,6 @@ async def test_show_config_form(hass: HomeAssistant) -> None: assert result["step_id"] == "user" -async def test_invalid_ip(hass: HomeAssistant) -> None: - """Test error in case of an invalid IP address.""" - with patch("getmac.get_mac_address", return_value=None): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": "invalid_ip"} - - -async def test_same_host(hass: HomeAssistant) -> None: - """Test abort in case of same host name.""" - with patch( - "aiodns.DNSResolver.query", - side_effect=aiodns.error.DNSError, - ), patch( - "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), - ): - unique_id = "mc.dummyserver.com-25565" - config_data = { - CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com", - CONF_PORT: DEFAULT_PORT, - } - mock_config_entry = MockConfigEntry( - domain=DOMAIN, unique_id=unique_id, data=config_data - ) - mock_config_entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "already_configured" - - async def test_port_too_small(hass: HomeAssistant) -> None: """Test error in case of a too small port.""" with patch( From dfee5d06a6a8531c948a4602220629c9f76cffa2 Mon Sep 17 00:00:00 2001 From: Pawel Date: Thu, 7 Sep 2023 12:45:31 +0200 Subject: [PATCH 190/640] Add support for more busy codes for Epson (#99771) add support for more busy codes --- homeassistant/components/epson/manifest.json | 2 +- homeassistant/components/epson/media_player.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/epson/manifest.json b/homeassistant/components/epson/manifest.json index 77a1a89b68660f..7b8f8d8a4a2b20 100644 --- a/homeassistant/components/epson/manifest.json +++ b/homeassistant/components/epson/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/epson", "iot_class": "local_polling", "loggers": ["epson_projector"], - "requirements": ["epson-projector==0.5.0"] + "requirements": ["epson-projector==0.5.1"] } diff --git a/homeassistant/components/epson/media_player.py b/homeassistant/components/epson/media_player.py index 5c49f566bb590d..1f80be9fe06a09 100644 --- a/homeassistant/components/epson/media_player.py +++ b/homeassistant/components/epson/media_player.py @@ -6,7 +6,7 @@ from epson_projector import Projector, ProjectorUnavailableError from epson_projector.const import ( BACK, - BUSY, + BUSY_CODES, CMODE, CMODE_LIST, CMODE_LIST_SET, @@ -147,7 +147,7 @@ async def async_update(self) -> None: self._attr_volume_level = float(volume) except ValueError: self._attr_volume_level = None - elif power_state == BUSY: + elif power_state in BUSY_CODES: self._attr_state = MediaPlayerState.ON else: self._attr_state = MediaPlayerState.OFF diff --git a/requirements_all.txt b/requirements_all.txt index 5ba5612db1204e..89c4cff5d3de96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -753,7 +753,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.epsonworkforce epsonprinter==0.0.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 62f6bdd23348f2..6d59aa4493f4df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -606,7 +606,7 @@ env-canada==0.5.36 ephem==4.1.2 # homeassistant.components.epson -epson-projector==0.5.0 +epson-projector==0.5.1 # homeassistant.components.esphome esphome-dashboard-api==1.2.3 From 306c7cd9a94694d9e1ccd88775a930b6cdd8a1ff Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 12:45:47 +0200 Subject: [PATCH 191/640] Use correct config entry id in Livisi (#99812) --- homeassistant/components/livisi/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/livisi/__init__.py b/homeassistant/components/livisi/__init__.py index b0387c6dcc9203..e638c84a917590 100644 --- a/homeassistant/components/livisi/__init__.py +++ b/homeassistant/components/livisi/__init__.py @@ -33,7 +33,7 @@ async def async_setup_entry(hass: core.HomeAssistant, entry: ConfigEntry) -> boo hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator device_registry = dr.async_get(hass) device_registry.async_get_or_create( - config_entry_id=coordinator.serial_number, + config_entry_id=entry.entry_id, identifiers={(DOMAIN, entry.entry_id)}, manufacturer="Livisi", name=f"SHC {coordinator.controller_type} {coordinator.serial_number}", From eee5705458bdbfd8c7b3cea3cc09ee21dec30ffb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 13:00:26 +0200 Subject: [PATCH 192/640] Fix typo in TrackTemplateResultInfo (#99809) --- homeassistant/helpers/event.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 1f74de497e2fd4..76e73401bebe95 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -1197,7 +1197,7 @@ def _apply_update( ) _LOGGER.debug( ( - "Template group %s listens for %s, re-render blocker by super" + "Template group %s listens for %s, re-render blocked by super" " template: %s" ), self._track_templates, From 368acaf6fd0e42ea5bdeb58c4855d417ce98faf6 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 7 Sep 2023 13:33:38 +0200 Subject: [PATCH 193/640] Improve error handling in /api/states POST (#99810) --- homeassistant/components/api/__init__.py | 23 ++++++++++++++++++----- tests/components/api/test_init.py | 22 ++++++++++++++++++++++ 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 10cf63b701dd46..6aead6e109f989 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -30,7 +30,13 @@ ) import homeassistant.core as ha from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceNotFound, TemplateError, Unauthorized +from homeassistant.exceptions import ( + InvalidEntityFormatError, + InvalidStateError, + ServiceNotFound, + TemplateError, + Unauthorized, +) from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.json import json_dumps from homeassistant.helpers.service import async_get_all_descriptions @@ -236,7 +242,7 @@ async def post(self, request, entity_id): """Update state of entity.""" if not request["hass_user"].is_admin: raise Unauthorized(entity_id=entity_id) - hass = request.app["hass"] + hass: HomeAssistant = request.app["hass"] try: data = await request.json() except ValueError: @@ -251,9 +257,16 @@ async def post(self, request, entity_id): is_new_state = hass.states.get(entity_id) is None # Write state - hass.states.async_set( - entity_id, new_state, attributes, force_update, self.context(request) - ) + try: + hass.states.async_set( + entity_id, new_state, attributes, force_update, self.context(request) + ) + except InvalidEntityFormatError: + return self.json_message( + "Invalid entity ID specified.", HTTPStatus.BAD_REQUEST + ) + except InvalidStateError: + return self.json_message("Invalid state specified.", HTTPStatus.BAD_REQUEST) # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index 38528b335b0b3f..2d5705403413eb 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -97,6 +97,28 @@ async def test_api_state_change_of_non_existing_entity( assert hass.states.get("test_entity.that_does_not_exist").state == new_state +async def test_api_state_change_with_bad_entity_id( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/bad.entity.id", json={"state": "new_state"} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + +async def test_api_state_change_with_bad_state( + hass: HomeAssistant, mock_api_client: TestClient +) -> None: + """Test if API sends appropriate error if we omit state.""" + resp = await mock_api_client.post( + "/api/states/test.test", json={"state": "x" * 256} + ) + + assert resp.status == HTTPStatus.BAD_REQUEST + + async def test_api_state_change_with_bad_data( hass: HomeAssistant, mock_api_client: TestClient ) -> None: From 8a4cd913b83f90ece6ae66482f0fd41baf55d319 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 13:53:44 +0200 Subject: [PATCH 194/640] Use shorthand attributes in Hisense (#99355) --- .../components/hisense_aehw4a1/climate.py | 166 ++++++------------ 1 file changed, 58 insertions(+), 108 deletions(-) diff --git a/homeassistant/components/hisense_aehw4a1/climate.py b/homeassistant/components/hisense_aehw4a1/climate.py index 113a0c622b9909..ca5ec694eab7c8 100644 --- a/homeassistant/components/hisense_aehw4a1/climate.py +++ b/homeassistant/components/hisense_aehw4a1/climate.py @@ -145,23 +145,19 @@ class ClimateAehW4a1(ClimateEntity): | ClimateEntityFeature.SWING_MODE | ClimateEntityFeature.PRESET_MODE ) + _attr_fan_modes = FAN_MODES + _attr_swing_modes = SWING_MODES + _attr_preset_modes = PRESET_MODES + _attr_available = False + _attr_target_temperature_step = 1 + _previous_state: HVACMode | str | None = None + _on: str | None = None def __init__(self, device): """Initialize the climate device.""" - self._unique_id = device + self._attr_unique_id = device + self._attr_name = device self._device = AehW4a1(device) - self._fan_modes = FAN_MODES - self._swing_modes = SWING_MODES - self._preset_modes = PRESET_MODES - self._attr_available = False - self._on = None - self._current_temperature = None - self._target_temperature = None - self._attr_hvac_mode = None - self._fan_mode = None - self._swing_mode = None - self._preset_mode = None - self._previous_state = None async def async_update(self) -> None: """Pull state from AEH-W4A1.""" @@ -169,7 +165,7 @@ async def async_update(self) -> None: status = await self._device.command("status_102_0") except pyaehw4a1.exceptions.ConnectionError as library_error: _LOGGER.warning( - "Unexpected error of %s: %s", self._unique_id, library_error + "Unexpected error of %s: %s", self._attr_unique_id, library_error ) self._attr_available = False return @@ -180,123 +176,65 @@ async def async_update(self) -> None: if status["temperature_Fahrenheit"] == "0": self._attr_temperature_unit = UnitOfTemperature.CELSIUS + self._attr_min_temp = MIN_TEMP_C + self._attr_max_temp = MAX_TEMP_C else: self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT + self._attr_min_temp = MIN_TEMP_F + self._attr_max_temp = MAX_TEMP_F - self._current_temperature = int(status["indoor_temperature_status"], 2) + self._attr_current_temperature = int(status["indoor_temperature_status"], 2) if self._on == "1": device_mode = status["mode_status"] self._attr_hvac_mode = AC_TO_HA_STATE[device_mode] fan_mode = status["wind_status"] - self._fan_mode = AC_TO_HA_FAN_MODES[fan_mode] + self._attr_fan_mode = AC_TO_HA_FAN_MODES[fan_mode] swing_mode = f'{status["up_down"]}{status["left_right"]}' - self._swing_mode = AC_TO_HA_SWING[swing_mode] + self._attr_swing_mode = AC_TO_HA_SWING[swing_mode] if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.HEAT): - self._target_temperature = int(status["indoor_temperature_setting"], 2) + self._attr_target_temperature = int( + status["indoor_temperature_setting"], 2 + ) else: - self._target_temperature = None + self._attr_target_temperature = None if status["efficient"] == "1": - self._preset_mode = PRESET_BOOST + self._attr_preset_mode = PRESET_BOOST elif status["low_electricity"] == "1": - self._preset_mode = PRESET_ECO + self._attr_preset_mode = PRESET_ECO elif status["sleep_status"] == "0000001": - self._preset_mode = PRESET_SLEEP + self._attr_preset_mode = PRESET_SLEEP elif status["sleep_status"] == "0000010": - self._preset_mode = "sleep_2" + self._attr_preset_mode = "sleep_2" elif status["sleep_status"] == "0000011": - self._preset_mode = "sleep_3" + self._attr_preset_mode = "sleep_3" elif status["sleep_status"] == "0000100": - self._preset_mode = "sleep_4" + self._attr_preset_mode = "sleep_4" else: - self._preset_mode = PRESET_NONE + self._attr_preset_mode = PRESET_NONE else: self._attr_hvac_mode = HVACMode.OFF - self._fan_mode = None - self._swing_mode = None - self._target_temperature = None - self._preset_mode = None - - @property - def name(self): - """Return the name of the climate device.""" - return self._unique_id - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we are trying to reach.""" - return self._target_temperature - - @property - def fan_mode(self): - """Return the fan setting.""" - return self._fan_mode - - @property - def fan_modes(self): - """Return the list of available fan modes.""" - return self._fan_modes - - @property - def preset_mode(self): - """Return the preset mode if on.""" - return self._preset_mode - - @property - def preset_modes(self): - """Return the list of available preset modes.""" - return self._preset_modes - - @property - def swing_mode(self): - """Return swing operation.""" - return self._swing_mode - - @property - def swing_modes(self): - """Return the list of available fan modes.""" - return self._swing_modes - - @property - def min_temp(self): - """Return the minimum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MIN_TEMP_C - return MIN_TEMP_F - - @property - def max_temp(self): - """Return the maximum temperature.""" - if self.temperature_unit == UnitOfTemperature.CELSIUS: - return MAX_TEMP_C - return MAX_TEMP_F - - @property - def target_temperature_step(self): - """Return the supported step of target temperature.""" - return 1 + self._attr_fan_mode = None + self._attr_swing_mode = None + self._attr_target_temperature = None + self._attr_preset_mode = None async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set temperature", self._unique_id + "AC at %s is off, could not set temperature", self._attr_unique_id ) return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: - _LOGGER.debug("Setting temp of %s to %s", self._unique_id, temp) - if self._preset_mode != PRESET_NONE: + _LOGGER.debug("Setting temp of %s to %s", self._attr_unique_id, temp) + if self._attr_preset_mode != PRESET_NONE: await self.async_set_preset_mode(PRESET_NONE) - if self.temperature_unit == UnitOfTemperature.CELSIUS: + if self._attr_temperature_unit == UnitOfTemperature.CELSIUS: await self._device.command(f"temp_{int(temp)}_C") else: await self._device.command(f"temp_{int(temp)}_F") @@ -304,24 +242,30 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new fan mode.""" if self._on != "1": - _LOGGER.warning("AC at %s is off, could not set fan mode", self._unique_id) + _LOGGER.warning( + "AC at %s is off, could not set fan mode", self._attr_unique_id + ) return if self._attr_hvac_mode in (HVACMode.COOL, HVACMode.FAN_ONLY) and ( self._attr_hvac_mode != HVACMode.FAN_ONLY or fan_mode != FAN_AUTO ): - _LOGGER.debug("Setting fan mode of %s to %s", self._unique_id, fan_mode) + _LOGGER.debug( + "Setting fan mode of %s to %s", self._attr_unique_id, fan_mode + ) await self._device.command(HA_FAN_MODES_TO_AC[fan_mode]) async def async_set_swing_mode(self, swing_mode: str) -> None: """Set new target swing operation.""" if self._on != "1": _LOGGER.warning( - "AC at %s is off, could not set swing mode", self._unique_id + "AC at %s is off, could not set swing mode", self._attr_unique_id ) return - _LOGGER.debug("Setting swing mode of %s to %s", self._unique_id, swing_mode) - swing_act = self._swing_mode + _LOGGER.debug( + "Setting swing mode of %s to %s", self._attr_unique_id, swing_mode + ) + swing_act = self._attr_swing_mode if swing_mode == SWING_OFF and swing_act != SWING_OFF: if swing_act in (SWING_HORIZONTAL, SWING_BOTH): @@ -354,7 +298,9 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: return await self.async_turn_on() - _LOGGER.debug("Setting preset mode of %s to %s", self._unique_id, preset_mode) + _LOGGER.debug( + "Setting preset mode of %s to %s", self._attr_unique_id, preset_mode + ) if preset_mode == PRESET_ECO: await self._device.command("energysave_on") @@ -379,13 +325,17 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self._device.command("energysave_off") elif self._previous_state == PRESET_BOOST: await self._device.command("turbo_off") - elif self._previous_state in HA_STATE_TO_AC: + elif self._previous_state in HA_STATE_TO_AC and isinstance( + self._previous_state, HVACMode + ): await self._device.command(HA_STATE_TO_AC[self._previous_state]) self._previous_state = None async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new operation mode.""" - _LOGGER.debug("Setting operation mode of %s to %s", self._unique_id, hvac_mode) + _LOGGER.debug( + "Setting operation mode of %s to %s", self._attr_unique_id, hvac_mode + ) if hvac_mode == HVACMode.OFF: await self.async_turn_off() else: @@ -395,10 +345,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: async def async_turn_on(self) -> None: """Turn on.""" - _LOGGER.debug("Turning %s on", self._unique_id) + _LOGGER.debug("Turning %s on", self._attr_unique_id) await self._device.command("on") async def async_turn_off(self) -> None: """Turn off.""" - _LOGGER.debug("Turning %s off", self._unique_id) + _LOGGER.debug("Turning %s off", self._attr_unique_id) await self._device.command("off") From 40a3d97230a7d85ff4338d719fc6676caa938fbd Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 13:55:16 +0200 Subject: [PATCH 195/640] Use shorthand attributes in Plex (#99769) Co-authored-by: Robert Resch --- homeassistant/components/plex/button.py | 12 ++++-------- homeassistant/components/plex/media_player.py | 7 ++++--- homeassistant/components/plex/sensor.py | 9 +++++---- 3 files changed, 13 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/plex/button.py b/homeassistant/components/plex/button.py index 58e0b78560b2c0..985b4ccb4e95fa 100644 --- a/homeassistant/components/plex/button.py +++ b/homeassistant/components/plex/button.py @@ -38,17 +38,13 @@ def __init__(self, server_id: str, server_name: str) -> None: self.server_id = server_id self._attr_name = f"Scan Clients ({server_name})" self._attr_unique_id = f"plex-scan_clients-{self.server_id}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, server_id)}, + manufacturer="Plex", + ) async def async_press(self) -> None: """Press the button.""" async_dispatcher_send( self.hass, PLEX_UPDATE_PLATFORMS_SIGNAL.format(self.server_id) ) - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - identifiers={(DOMAIN, self.server_id)}, - manufacturer="Plex", - ) diff --git a/homeassistant/components/plex/media_player.py b/homeassistant/components/plex/media_player.py index 23f2895fd51b69..3e6875f98b9edb 100644 --- a/homeassistant/components/plex/media_player.py +++ b/homeassistant/components/plex/media_player.py @@ -117,6 +117,10 @@ def _async_add_entities(hass, registry, async_add_entities, server_id, new_entit class PlexMediaPlayer(MediaPlayerEntity): """Representation of a Plex device.""" + _attr_available = False + _attr_should_poll = False + _attr_state = MediaPlayerState.IDLE + def __init__(self, plex_server, device, player_source, session=None): """Initialize the Plex device.""" self.plex_server = plex_server @@ -136,9 +140,6 @@ def __init__(self, plex_server, device, player_source, session=None): self._volume_level = 1 # since we can't retrieve remotely self._volume_muted = False # since we can't retrieve remotely - self._attr_available = False - self._attr_should_poll = False - self._attr_state = MediaPlayerState.IDLE self._attr_unique_id = ( f"{self.plex_server.machine_identifier}:{self.machine_identifier}" ) diff --git a/homeassistant/components/plex/sensor.py b/homeassistant/components/plex/sensor.py index a705d11cb41e8f..972cd8d4bc9e45 100644 --- a/homeassistant/components/plex/sensor.py +++ b/homeassistant/components/plex/sensor.py @@ -129,6 +129,11 @@ def device_info(self) -> DeviceInfo | None: class PlexLibrarySectionSensor(SensorEntity): """Representation of a Plex library section sensor.""" + _attr_available = True + _attr_entity_registry_enabled_default = False + _attr_should_poll = False + _attr_native_unit_of_measurement = "Items" + def __init__(self, hass, plex_server, plex_library_section): """Initialize the sensor.""" self._server = plex_server @@ -137,14 +142,10 @@ def __init__(self, hass, plex_server, plex_library_section): self.library_section = plex_library_section self.library_type = plex_library_section.type - self._attr_available = True - self._attr_entity_registry_enabled_default = False self._attr_extra_state_attributes = {} self._attr_icon = LIBRARY_ICON_LOOKUP.get(self.library_type, "mdi:plex") self._attr_name = f"{self.server_name} Library - {plex_library_section.title}" - self._attr_should_poll = False self._attr_unique_id = f"library-{self.server_id}-{plex_library_section.uuid}" - self._attr_native_unit_of_measurement = "Items" async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" From c9a1836d45bebde1522979abeee0a07245c9cbfd Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Thu, 7 Sep 2023 14:54:56 +0200 Subject: [PATCH 196/640] Update coverage to 7.3.1 (#99805) --- requirements_test.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index 4095d6732c97eb..ba636c566490e4 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==2.15.4 -coverage==7.3.0 +coverage==7.3.1 freezegun==1.2.2 mock-open==1.4.0 mypy==1.5.1 From 0d6f202eb34e923d0cbdec443a21f4b28891ad63 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 7 Sep 2023 15:26:57 +0200 Subject: [PATCH 197/640] Change AVM FRITZ!Box Call monitor sensor into an enum (#99762) Co-authored-by: Joost Lekkerkerker Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com> --- .../components/fritzbox_callmonitor/sensor.py | 5 ++++- .../components/fritzbox_callmonitor/strings.json | 12 ++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 43cdb29f85f0a5..11c3166fd88297 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -12,7 +12,7 @@ from fritzconnection.core.fritzmonitor import FritzMonitor -from homeassistant.components.sensor import SensorEntity +from homeassistant.components.sensor import SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant @@ -82,6 +82,9 @@ class FritzBoxCallSensor(SensorEntity): """Implementation of a Fritz!Box call monitor.""" _attr_icon = ICON_PHONE + _attr_translation_key = DOMAIN + _attr_device_class = SensorDeviceClass.ENUM + _attr_options = list(CallState) def __init__( self, diff --git a/homeassistant/components/fritzbox_callmonitor/strings.json b/homeassistant/components/fritzbox_callmonitor/strings.json index 6b2fa2943f9961..89f049bfbe90d2 100644 --- a/homeassistant/components/fritzbox_callmonitor/strings.json +++ b/homeassistant/components/fritzbox_callmonitor/strings.json @@ -37,5 +37,17 @@ "error": { "malformed_prefixes": "Prefixes are malformed, please check their format." } + }, + "entity": { + "sensor": { + "fritzbox_callmonitor": { + "state": { + "ringing": "Ringing", + "dialing": "Dialing", + "talking": "Talking", + "idle": "[%key:common::state::idle%]" + } + } + } } } From 526b5871709a90b7f7a3af8bf58dbfa482825060 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:32:03 +0200 Subject: [PATCH 198/640] Remove unused variable from rainbird (#99824) --- homeassistant/components/rainbird/switch.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/rainbird/switch.py b/homeassistant/components/rainbird/switch.py index ac42e00c676084..39bb4a7b0d1caa 100644 --- a/homeassistant/components/rainbird/switch.py +++ b/homeassistant/components/rainbird/switch.py @@ -71,7 +71,6 @@ def __init__( else: self._attr_name = None self._attr_has_entity_name = True - self._state = None self._duration_minutes = duration_minutes self._attr_unique_id = f"{coordinator.serial_number}-{zone}" self._attr_device_info = DeviceInfo( From 1fe17b5bedce6421a57f53ed0b82c27eb69a47f0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:56:21 +0200 Subject: [PATCH 199/640] Use shorthand attributes in Sense (#99833) --- .../components/sense/binary_sensor.py | 46 ++++--------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 2aee20be5aecf2..094ecbdfcf753d 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -74,53 +74,23 @@ class SenseDevice(BinarySensorEntity): _attr_attribution = ATTRIBUTION _attr_should_poll = False + _attr_available = False + _attr_device_class = BinarySensorDeviceClass.POWER def __init__(self, sense_devices_data, device, sense_monitor_id): """Initialize the Sense binary sensor.""" - self._name = device["name"] + self._attr_name = device["name"] self._id = device["id"] self._sense_monitor_id = sense_monitor_id - self._unique_id = f"{sense_monitor_id}-{self._id}" - self._icon = sense_to_mdi(device["icon"]) + self._attr_unique_id = f"{sense_monitor_id}-{self._id}" + self._attr_icon = sense_to_mdi(device["icon"]) self._sense_devices_data = sense_devices_data - self._state = None - self._available = False - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def available(self): - """Return the availability of the binary sensor.""" - return self._available - - @property - def name(self): - """Return the name of the binary sensor.""" - return self._name - - @property - def unique_id(self): - """Return the unique id of the binary sensor.""" - return self._unique_id @property def old_unique_id(self): """Return the old not so unique id of the binary sensor.""" return self._id - @property - def icon(self): - """Return the icon of the binary sensor.""" - return self._icon - - @property - def device_class(self): - """Return the device class of the binary sensor.""" - return BinarySensorDeviceClass.POWER - async def async_added_to_hass(self) -> None: """Register callbacks.""" self.async_on_remove( @@ -135,8 +105,8 @@ async def async_added_to_hass(self) -> None: def _async_update_from_data(self): """Get the latest data, update state. Must not do I/O.""" new_state = bool(self._sense_devices_data.get_device_by_id(self._id)) - if self._available and self._state == new_state: + if self._attr_available and self._attr_is_on == new_state: return - self._available = True - self._state = new_state + self._attr_available = True + self._attr_is_on = new_state self.async_write_ha_state() From 114b5bd1f005a7b43dd9256f934dafeec7ddb347 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 15:56:40 +0200 Subject: [PATCH 200/640] Use shorthand attributes in Roomba (#99831) --- homeassistant/components/roomba/binary_sensor.py | 7 +------ homeassistant/components/roomba/braava.py | 14 +++----------- homeassistant/components/roomba/irobot_base.py | 12 ++---------- homeassistant/components/roomba/roomba.py | 11 ++--------- 4 files changed, 8 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/roomba/binary_sensor.py b/homeassistant/components/roomba/binary_sensor.py index f480839388ca46..cd37e089c9f69a 100644 --- a/homeassistant/components/roomba/binary_sensor.py +++ b/homeassistant/components/roomba/binary_sensor.py @@ -27,7 +27,7 @@ async def async_setup_entry( class RoombaBinStatus(IRobotEntity, BinarySensorEntity): """Class to hold Roomba Sensor basic info.""" - ICON = "mdi:delete-variant" + _attr_icon = "mdi:delete-variant" _attr_translation_key = "bin_full" @property @@ -35,11 +35,6 @@ def unique_id(self): """Return the ID of this sensor.""" return f"bin_{self._blid}" - @property - def icon(self): - """Return the icon of this sensor.""" - return self.ICON - @property def is_on(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/roomba/braava.py b/homeassistant/components/roomba/braava.py index ea08829cba6ece..db517a065ead65 100644 --- a/homeassistant/components/roomba/braava.py +++ b/homeassistant/components/roomba/braava.py @@ -29,6 +29,8 @@ class BraavaJet(IRobotVacuum): """Braava Jet.""" + _attr_supported_features = SUPPORT_BRAAVA + def __init__(self, roomba, blid): """Initialize the Roomba handler.""" super().__init__(roomba, blid) @@ -38,12 +40,7 @@ def __init__(self, roomba, blid): for behavior in BRAAVA_MOP_BEHAVIORS: for spray in BRAAVA_SPRAY_AMOUNT: speed_list.append(f"{behavior}-{spray}") - self._speed_list = speed_list - - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_BRAAVA + self._attr_fan_speed_list = speed_list @property def fan_speed(self): @@ -62,11 +59,6 @@ def fan_speed(self): pad_wetness_value = pad_wetness.get("disposable") return f"{behavior}-{pad_wetness_value}" - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return self._speed_list - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" try: diff --git a/homeassistant/components/roomba/irobot_base.py b/homeassistant/components/roomba/irobot_base.py index 8b909392250494..a48b363860836a 100644 --- a/homeassistant/components/roomba/irobot_base.py +++ b/homeassistant/components/roomba/irobot_base.py @@ -138,17 +138,14 @@ class IRobotVacuum(IRobotEntity, StateVacuumEntity): """Base class for iRobot robots.""" _attr_name = None + _attr_supported_features = SUPPORT_IROBOT + _attr_available = True # Always available, otherwise setup will fail def __init__(self, roomba, blid): """Initialize the iRobot handler.""" super().__init__(roomba, blid) self._cap_position = self.vacuum_state.get("cap", {}).get("pose") == 1 - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_IROBOT - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" @@ -159,11 +156,6 @@ def state(self): """Return the state of the vacuum cleaner.""" return self._robot_state - @property - def available(self) -> bool: - """Return True if entity is available.""" - return True # Always available, otherwise setup will fail - @property def extra_state_attributes(self): """Return the state attributes of the device.""" diff --git a/homeassistant/components/roomba/roomba.py b/homeassistant/components/roomba/roomba.py index 7cac9a3ba52b92..2c50508a637e76 100644 --- a/homeassistant/components/roomba/roomba.py +++ b/homeassistant/components/roomba/roomba.py @@ -42,10 +42,8 @@ def extra_state_attributes(self): class RoombaVacuumCarpetBoost(RoombaVacuum): """Roomba robot with carpet boost.""" - @property - def supported_features(self): - """Flag vacuum cleaner robot features that are supported.""" - return SUPPORT_ROOMBA_CARPET_BOOST + _attr_fan_speed_list = FAN_SPEEDS + _attr_supported_features = SUPPORT_ROOMBA_CARPET_BOOST @property def fan_speed(self): @@ -62,11 +60,6 @@ def fan_speed(self): fan_speed = FAN_SPEED_ECO return fan_speed - @property - def fan_speed_list(self): - """Get the list of available fan speed steps of the vacuum cleaner.""" - return FAN_SPEEDS - async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" if fan_speed.capitalize() in FAN_SPEEDS: From 2a3ebbc26cb4f09d561d5bde71ef420297b6744e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:08:44 +0200 Subject: [PATCH 201/640] Use shorthand attributes in SharkIQ (#99836) --- homeassistant/components/sharkiq/vacuum.py | 23 ++++++++-------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/sharkiq/vacuum.py b/homeassistant/components/sharkiq/vacuum.py index 8c6c4a9197a0d1..9510b7d3f66029 100644 --- a/homeassistant/components/sharkiq/vacuum.py +++ b/homeassistant/components/sharkiq/vacuum.py @@ -88,7 +88,13 @@ def __init__( super().__init__(coordinator) self.sharkiq = sharkiq self._attr_unique_id = sharkiq.serial_number - self._serial_number = sharkiq.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, sharkiq.serial_number)}, + manufacturer=SHARK, + model=self.model, + name=sharkiq.name, + sw_version=sharkiq.get_property_value(Properties.ROBOT_FIRMWARE_VERSION), + ) def clean_spot(self, **kwargs: Any) -> None: """Clean a spot. Not yet implemented.""" @@ -106,7 +112,7 @@ def send_command( @property def is_online(self) -> bool: """Tell us if the device is online.""" - return self.coordinator.device_is_online(self._serial_number) + return self.coordinator.device_is_online(self.sharkiq.serial_number) @property def model(self) -> str: @@ -115,19 +121,6 @@ def model(self) -> str: return self.sharkiq.vac_model_number return self.sharkiq.oem_model_number - @property - def device_info(self) -> DeviceInfo: - """Device info dictionary.""" - return DeviceInfo( - identifiers={(DOMAIN, self._serial_number)}, - manufacturer=SHARK, - model=self.model, - name=self.sharkiq.name, - sw_version=self.sharkiq.get_property_value( - Properties.ROBOT_FIRMWARE_VERSION - ), - ) - @property def error_code(self) -> int | None: """Return the last observed error code (or None).""" From d9b48b03f70b832a149d0778fbd64c643e9690aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:20:57 +0200 Subject: [PATCH 202/640] Use shorthand attributes in Rachio (#99823) --- .../components/rachio/binary_sensor.py | 22 ++--- homeassistant/components/rachio/switch.py | 84 ++++++------------- 2 files changed, 32 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/rachio/binary_sensor.py b/homeassistant/components/rachio/binary_sensor.py index 029b1bac6e3500..652806a2bada45 100644 --- a/homeassistant/components/rachio/binary_sensor.py +++ b/homeassistant/components/rachio/binary_sensor.py @@ -59,16 +59,6 @@ class RachioControllerBinarySensor(RachioDevice, BinarySensorEntity): _attr_has_entity_name = True - def __init__(self, controller): - """Set up a new Rachio controller binary sensor.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the sensor has a 'true' value.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -98,15 +88,15 @@ def unique_id(self) -> str: def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" if args[0][0][KEY_SUBTYPE] in (SUBTYPE_ONLINE, SUBTYPE_COLD_REBOOT): - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE + self._attr_is_on = self._controller.init_data[KEY_STATUS] == STATUS_ONLINE self.async_on_remove( async_dispatcher_connect( @@ -132,15 +122,15 @@ def unique_id(self) -> str: def _async_handle_update(self, *args, **kwargs) -> None: """Handle an update to the state of this sensor.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_ON: - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_SENSOR_DETECTION_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] + self._attr_is_on = self._controller.init_data[KEY_RAIN_SENSOR_TRIPPED] self.async_on_remove( async_dispatcher_connect( diff --git a/homeassistant/components/rachio/switch.py b/homeassistant/components/rachio/switch.py index 0557a2bdb19b72..bbb08f6d46fe94 100644 --- a/homeassistant/components/rachio/switch.py +++ b/homeassistant/components/rachio/switch.py @@ -178,16 +178,6 @@ def _create_entities(hass: HomeAssistant, config_entry: ConfigEntry) -> list[Ent class RachioSwitch(RachioDevice, SwitchEntity): """Represent a Rachio state that can be toggled.""" - def __init__(self, controller): - """Initialize a new Rachio switch.""" - super().__init__(controller) - self._state = None - - @property - def is_on(self) -> bool: - """Return whether the switch is currently on.""" - return self._state - @callback def _async_handle_any_update(self, *args, **kwargs) -> None: """Determine whether an update event applies to this device.""" @@ -219,9 +209,9 @@ def unique_id(self) -> str: def _async_handle_update(self, *args, **kwargs) -> None: """Update the state using webhook data.""" if args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: - self._state = True + self._attr_is_on = True elif args[0][0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @@ -236,7 +226,7 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_ON in self._controller.init_data: - self._state = not self._controller.init_data[KEY_ON] + self._attr_is_on = not self._controller.init_data[KEY_ON] self.async_on_remove( async_dispatcher_connect( @@ -274,20 +264,20 @@ def _async_handle_update(self, *args, **kwargs) -> None: if args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_ON: endtime = parse_datetime(args[0][0][KEY_RAIN_DELAY_END]) _LOGGER.debug("Rain delay expires at %s", endtime) - self._state = True + self._attr_is_on = True assert endtime is not None self._cancel_update = async_track_point_in_utc_time( self.hass, self._delay_expiration, endtime ) elif args[0][0][KEY_SUBTYPE] == SUBTYPE_RAIN_DELAY_OFF: - self._state = False + self._attr_is_on = False self.async_write_ha_state() @callback def _delay_expiration(self, *args) -> None: """Trigger when a rain delay expires.""" - self._state = False + self._attr_is_on = False self._cancel_update = None self.async_write_ha_state() @@ -304,12 +294,12 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to updates.""" if KEY_RAIN_DELAY in self._controller.init_data: - self._state = self._controller.init_data[ + self._attr_is_on = self._controller.init_data[ KEY_RAIN_DELAY ] / 1000 > as_timestamp(now()) # If the controller was in a rain delay state during a reboot, this re-sets the timer - if self._state is True: + if self._attr_is_on is True: delay_end = utc_from_timestamp( self._controller.init_data[KEY_RAIN_DELAY] / 1000 ) @@ -330,19 +320,22 @@ async def async_added_to_hass(self) -> None: class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" + _attr_icon = "mdi:water" + def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Zone.""" self.id = data[KEY_ID] - self._zone_name = data[KEY_NAME] + self._attr_name = data[KEY_NAME] self._zone_number = data[KEY_ZONE_NUMBER] self._zone_enabled = data[KEY_ENABLED] - self._entity_picture = data.get(KEY_IMAGE_URL) + self._attr_entity_picture = data.get(KEY_IMAGE_URL) self._person = person self._shade_type = data.get(KEY_CUSTOM_SHADE, {}).get(KEY_NAME) self._zone_type = data.get(KEY_CUSTOM_CROP, {}).get(KEY_NAME) self._slope_type = data.get(KEY_CUSTOM_SLOPE, {}).get(KEY_NAME) self._summary = "" self._current_schedule = current_schedule + self._attr_unique_id = f"{controller.controller_id}-zone-{self.id}" super().__init__(controller) def __str__(self): @@ -354,31 +347,11 @@ def zone_id(self) -> str: """How the Rachio API refers to the zone.""" return self.id - @property - def name(self) -> str: - """Return the friendly name of the zone.""" - return self._zone_name - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and zone number.""" - return f"{self._controller.controller_id}-zone-{self.zone_id}" - - @property - def icon(self) -> str: - """Return the icon to display.""" - return "mdi:water" - @property def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" return self._zone_enabled - @property - def entity_picture(self): - """Return the entity picture to use in the frontend, if any.""" - return self._entity_picture - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the optional state attributes.""" @@ -424,7 +397,7 @@ def turn_off(self, **kwargs: Any) -> None: def set_moisture_percent(self, percent) -> None: """Set the zone moisture percent.""" - _LOGGER.debug("Setting %s moisture to %s percent", self._zone_name, percent) + _LOGGER.debug("Setting %s moisture to %s percent", self.name, percent) self._controller.rachio.zone.set_moisture_percent(self.id, percent / 100) @callback @@ -436,19 +409,19 @@ def _async_handle_update(self, *args, **kwargs) -> None: self._summary = args[0][KEY_SUMMARY] if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_ZONE_STOPPED, SUBTYPE_ZONE_COMPLETED, SUBTYPE_ZONE_PAUSED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) + self._attr_is_on = self.zone_id == self._current_schedule.get(KEY_ZONE_ID) self.async_on_remove( async_dispatcher_connect( @@ -463,24 +436,17 @@ class RachioSchedule(RachioSwitch): def __init__(self, person, controller, data, current_schedule): """Initialize a new Rachio Schedule.""" self._schedule_id = data[KEY_ID] - self._schedule_name = data[KEY_NAME] self._duration = data[KEY_DURATION] self._schedule_enabled = data[KEY_ENABLED] self._summary = data[KEY_SUMMARY] self.type = data.get(KEY_TYPE, SCHEDULE_TYPE_FIXED) self._current_schedule = current_schedule + self._attr_unique_id = ( + f"{controller.controller_id}-schedule-{self._schedule_id}" + ) + self._attr_name = f"{data[KEY_NAME]} Schedule" super().__init__(controller) - @property - def name(self) -> str: - """Return the friendly name of the schedule.""" - return f"{self._schedule_name} Schedule" - - @property - def unique_id(self) -> str: - """Return a unique id by combining controller id and schedule.""" - return f"{self._controller.controller_id}-schedule-{self._schedule_id}" - @property def icon(self) -> str: """Return the icon to display.""" @@ -521,18 +487,20 @@ def _async_handle_update(self, *args, **kwargs) -> None: with suppress(KeyError): if args[0][KEY_SCHEDULE_ID] == self._schedule_id: if args[0][KEY_SUBTYPE] in [SUBTYPE_SCHEDULE_STARTED]: - self._state = True + self._attr_is_on = True elif args[0][KEY_SUBTYPE] in [ SUBTYPE_SCHEDULE_STOPPED, SUBTYPE_SCHEDULE_COMPLETED, ]: - self._state = False + self._attr_is_on = False self.async_write_ha_state() async def async_added_to_hass(self) -> None: """Subscribe to updates.""" - self._state = self._schedule_id == self._current_schedule.get(KEY_SCHEDULE_ID) + self._attr_is_on = self._schedule_id == self._current_schedule.get( + KEY_SCHEDULE_ID + ) self.async_on_remove( async_dispatcher_connect( From 73651dbffd6fac6ba2aa68f08f0cba1db3cc7e18 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 16:37:30 +0200 Subject: [PATCH 203/640] Use shorthand attributes in Snapcast (#99840) --- .../components/snapcast/media_player.py | 18 +++--------------- 1 file changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/snapcast/media_player.py b/homeassistant/components/snapcast/media_player.py index 9dadae2e3e2a55..f0b6eccf8b40da 100644 --- a/homeassistant/components/snapcast/media_player.py +++ b/homeassistant/components/snapcast/media_player.py @@ -160,7 +160,7 @@ def __init__(self, group, uid_part, entry_id): self._attr_available = True self._group = group self._entry_id = entry_id - self._uid = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" + self._attr_unique_id = f"{GROUP_PREFIX}{uid_part}_{self._group.identifier}" async def async_added_to_hass(self) -> None: """Subscribe to group events.""" @@ -184,11 +184,6 @@ def state(self) -> MediaPlayerState | None: return MediaPlayerState.IDLE return STREAM_STATUS.get(self._group.stream_status) - @property - def unique_id(self): - """Return the ID of snapcast group.""" - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" @@ -260,7 +255,8 @@ def __init__(self, client, uid_part, entry_id): """Initialize the Snapcast client device.""" self._attr_available = True self._client = client - self._uid = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" + # Note: Host part is needed, when using multiple snapservers + self._attr_unique_id = f"{CLIENT_PREFIX}{uid_part}_{self._client.identifier}" self._entry_id = entry_id async def async_added_to_hass(self) -> None: @@ -278,14 +274,6 @@ def set_availability(self, available: bool) -> None: self._attr_available = available self.schedule_update_ha_state() - @property - def unique_id(self): - """Return the ID of this snapcast client. - - Note: Host part is needed, when using multiple snapservers - """ - return self._uid - @property def identifier(self): """Return the snapcast identifier.""" From 69f6a115b6689b7862d7c2fda10eb3ef699305df Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 17:28:13 +0200 Subject: [PATCH 204/640] Move shorthand attributes out of constructor in Sensibo (#99834) Use shorthand attributes in Sensibo --- homeassistant/components/sensibo/climate.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensibo/climate.py b/homeassistant/components/sensibo/climate.py index da86ba8fe2464e..3529627b49788c 100644 --- a/homeassistant/components/sensibo/climate.py +++ b/homeassistant/components/sensibo/climate.py @@ -180,6 +180,8 @@ class SensiboClimate(SensiboDeviceBaseEntity, ClimateEntity): """Representation of a Sensibo device.""" _attr_name = None + _attr_precision = PRECISION_TENTHS + _attr_translation_key = "climate_device" def __init__( self, coordinator: SensiboDataUpdateCoordinator, device_id: str @@ -193,8 +195,6 @@ def __init__( else UnitOfTemperature.FAHRENHEIT ) self._attr_supported_features = self.get_features() - self._attr_precision = PRECISION_TENTHS - self._attr_translation_key = "climate_device" def get_features(self) -> ClimateEntityFeature: """Get supported features.""" From c3e14d051431a7bdfef365bb5af270a7fcde30ee Mon Sep 17 00:00:00 2001 From: Quentame Date: Thu, 7 Sep 2023 17:28:50 +0200 Subject: [PATCH 205/640] Fix Freebox Home battery sensor (#99756) --- homeassistant/components/freebox/const.py | 3 +++ tests/components/freebox/const.py | 6 ++--- tests/components/freebox/test_sensor.py | 28 ++++++++++++++++++++++- 3 files changed, 33 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/freebox/const.py b/homeassistant/components/freebox/const.py index 59dce75649be7c..5bed7b3456aeba 100644 --- a/homeassistant/components/freebox/const.py +++ b/homeassistant/components/freebox/const.py @@ -85,4 +85,7 @@ class FreeboxHomeCategory(enum.StrEnum): HOME_COMPATIBLE_CATEGORIES = [ FreeboxHomeCategory.CAMERA, + FreeboxHomeCategory.DWS, + FreeboxHomeCategory.KFB, + FreeboxHomeCategory.PIR, ] diff --git a/tests/components/freebox/const.py b/tests/components/freebox/const.py index a6253dbf3154ec..0b58348a5dfb86 100644 --- a/tests/components/freebox/const.py +++ b/tests/components/freebox/const.py @@ -1986,7 +1986,7 @@ "category": "kfb", "group": {"label": ""}, "id": 9, - "label": "Télécommande I", + "label": "Télécommande", "name": "node_9", "props": { "Address": 5, @@ -2067,7 +2067,7 @@ "category": "dws", "group": {"label": "Entrée"}, "id": 11, - "label": "dws i", + "label": "Ouverture porte", "name": "node_11", "props": { "Address": 6, @@ -2259,7 +2259,7 @@ "category": "pir", "group": {"label": "Salon"}, "id": 26, - "label": "Salon Détecteur s", + "label": "Détecteur", "name": "node_26", "props": { "Address": 9, diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 2ebcf8baa04239..41daa79fe4e00a 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -9,7 +9,7 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_STORAGE_GET_DISKS +from .const import DATA_HOME_GET_NODES, DATA_STORAGE_GET_DISKS from tests.common import async_fire_time_changed @@ -43,3 +43,29 @@ async def test_disk( # To execute the save await hass.async_block_till_done() assert hass.states.get("sensor.freebox_free_space").state == "44.9" + + +async def test_battery( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test battery sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "100" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "100" + + # Simulate a changed battery + data_home_get_nodes_changed = deepcopy(DATA_HOME_GET_NODES) + data_home_get_nodes_changed[2]["show_endpoints"][3]["value"] = 25 + data_home_get_nodes_changed[3]["show_endpoints"][3]["value"] = 50 + data_home_get_nodes_changed[4]["show_endpoints"][3]["value"] = 75 + router().home.get_home_nodes.return_value = data_home_get_nodes_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.telecommande_niveau_de_batterie").state == "25" + assert hass.states.get("sensor.ouverture_porte_niveau_de_batterie").state == "50" + assert hass.states.get("sensor.detecteur_niveau_de_batterie").state == "75" From c567a2c3d4b036839057888621cdb687dc58c480 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 17:36:07 +0200 Subject: [PATCH 206/640] Move unit of temperature to descriptions in Sensibo (#99835) --- homeassistant/components/sensibo/sensor.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 7208902456ea8b..547504d7889265 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -107,6 +107,7 @@ class SensiboDeviceSensorEntityDescription( SensiboMotionSensorEntityDescription( key="temperature", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, icon="mdi:thermometer", value_fn=lambda data: data.temperature, @@ -145,6 +146,7 @@ class SensiboDeviceSensorEntityDescription( key="feels_like", translation_key="feels_like", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.feelslike, extra_fn=None, @@ -154,6 +156,7 @@ class SensiboDeviceSensorEntityDescription( key="climate_react_low", translation_key="climate_react_low", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_low_temp_threshold, extra_fn=lambda data: data.smart_low_state, @@ -163,6 +166,7 @@ class SensiboDeviceSensorEntityDescription( key="climate_react_high", translation_key="climate_react_high", device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, state_class=SensorStateClass.MEASUREMENT, value_fn=lambda data: data.smart_high_temp_threshold, extra_fn=lambda data: data.smart_high_state, @@ -299,13 +303,6 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{sensor_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType: """Return value of sensor.""" @@ -333,13 +330,6 @@ def __init__( self.entity_description = entity_description self._attr_unique_id = f"{device_id}-{entity_description.key}" - @property - def native_unit_of_measurement(self) -> str | None: - """Add native unit of measurement.""" - if self.entity_description.device_class == SensorDeviceClass.TEMPERATURE: - return UnitOfTemperature.CELSIUS - return self.entity_description.native_unit_of_measurement - @property def native_value(self) -> StateType | datetime: """Return value of sensor.""" From 94aec3e590c026176c101fcf5653dd35024d7aa3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 18:30:58 +0200 Subject: [PATCH 207/640] Use shorthand attributes in Opentherm gateway (#99630) --- .../components/opentherm_gw/binary_sensor.py | 60 ++++---------- .../components/opentherm_gw/climate.py | 81 ++++++------------- .../components/opentherm_gw/sensor.py | 65 ++++----------- 3 files changed, 55 insertions(+), 151 deletions(-) diff --git a/homeassistant/components/opentherm_gw/binary_sensor.py b/homeassistant/components/opentherm_gw/binary_sensor.py index 7f2a05ddf03a4d..d6aa5a3b700813 100644 --- a/homeassistant/components/opentherm_gw/binary_sensor.py +++ b/homeassistant/components/opentherm_gw/binary_sensor.py @@ -52,6 +52,7 @@ class OpenThermBinarySensor(BinarySensorEntity): """Represent an OpenTherm Gateway binary sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, friendly_name_format): """Initialize the binary sensor.""" @@ -61,73 +62,42 @@ def __init__(self, gw_dev, var, source, device_class, friendly_name_format): self._gateway = gw_dev self._var = var self._source = source - self._state = None - self._device_class = device_class + self._attr_device_class = device_class if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug( - "Removing OpenTherm Gateway binary sensor %s", self._friendly_name - ) + _LOGGER.debug("Removing OpenTherm Gateway binary sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._state is not None - - @property - def entity_registry_enabled_default(self): - """Disable binary_sensors by default.""" - return False + return self._attr_is_on is not None @callback def receive_report(self, status): """Handle status updates from the component.""" state = status[self._source].get(self._var) - self._state = None if state is None else bool(state) + self._attr_is_on = None if state is None else bool(state) self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def is_on(self): - """Return true if the binary sensor is on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device.""" - return self._device_class diff --git a/homeassistant/components/opentherm_gw/climate.py b/homeassistant/components/opentherm_gw/climate.py index b34239c933afd8..bcad621eb82d6b 100644 --- a/homeassistant/components/opentherm_gw/climate.py +++ b/homeassistant/components/opentherm_gw/climate.py @@ -70,6 +70,20 @@ class OpenThermClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_available = False + _attr_hvac_modes = [] + _attr_preset_modes = [] + _attr_min_temp = 1 + _attr_max_temp = 30 + _hvac_mode = HVACMode.HEAT + _current_temperature: float | None = None + _new_target_temperature: float | None = None + _target_temperature: float | None = None + _away_mode_a: int | None = None + _away_mode_b: int | None = None + _away_state_a = False + _away_state_b = False + _current_operation: HVACAction | None = None def __init__(self, gw_dev, options): """Initialize the device.""" @@ -78,22 +92,21 @@ def __init__(self, gw_dev, options): ENTITY_ID_FORMAT, gw_dev.gw_id, hass=gw_dev.hass ) self.friendly_name = gw_dev.name + self._attr_name = self.friendly_name self.floor_temp = options.get(CONF_FLOOR_TEMP, DEFAULT_FLOOR_TEMP) self.temp_read_precision = options.get(CONF_READ_PRECISION) self.temp_set_precision = options.get(CONF_SET_PRECISION) self.temporary_ovrd_mode = options.get(CONF_TEMPORARY_OVRD_MODE, True) - self._available = False - self._current_operation: HVACAction | None = None - self._current_temperature = None - self._hvac_mode = HVACMode.HEAT - self._new_target_temperature = None - self._target_temperature = None - self._away_mode_a = None - self._away_mode_b = None - self._away_state_a = False - self._away_state_b = False self._unsub_options = None self._unsub_updates = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) + self._attr_unique_id = gw_dev.gw_id @callback def update_options(self, entry): @@ -123,7 +136,7 @@ async def async_will_remove_from_hass(self) -> None: @callback def receive_report(self, status): """Receive and handle a new report from the Gateway.""" - self._available = status != gw_vars.DEFAULT_STATUS + self._attr_available = status != gw_vars.DEFAULT_STATUS ch_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_CH_ACTIVE) flame_on = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_FLAME_ON) cooling_active = status[gw_vars.BOILER].get(gw_vars.DATA_SLAVE_COOLING_ACTIVE) @@ -171,32 +184,6 @@ def receive_report(self, status): ) self.async_write_ha_state() - @property - def available(self): - """Return availability of the sensor.""" - return self._available - - @property - def name(self): - """Return the friendly name.""" - return self.friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return self._gateway.gw_id - @property def precision(self): """Return the precision of the system.""" @@ -216,11 +203,6 @@ def hvac_mode(self) -> HVACMode: """Return current HVAC mode.""" return self._hvac_mode - @property - def hvac_modes(self) -> list[HVACMode]: - """Return available HVAC modes.""" - return [] - def set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set the HVAC mode.""" _LOGGER.warning("Changing HVAC mode is not supported") @@ -259,11 +241,6 @@ def preset_mode(self): return PRESET_AWAY return PRESET_NONE - @property - def preset_modes(self): - """Available preset modes to set.""" - return [] - def set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" _LOGGER.warning("Changing preset mode is not supported") @@ -278,13 +255,3 @@ async def async_set_temperature(self, **kwargs: Any) -> None: temp, self.temporary_ovrd_mode ) self.async_write_ha_state() - - @property - def min_temp(self): - """Return the minimum temperature.""" - return 1 - - @property - def max_temp(self): - """Return the maximum temperature.""" - return 30 diff --git a/homeassistant/components/opentherm_gw/sensor.py b/homeassistant/components/opentherm_gw/sensor.py index df9260d7d19275..09fbb0ef6ee9ea 100644 --- a/homeassistant/components/opentherm_gw/sensor.py +++ b/homeassistant/components/opentherm_gw/sensor.py @@ -49,6 +49,7 @@ class OpenThermSensor(SensorEntity): """Representation of an OpenTherm Gateway sensor.""" _attr_should_poll = False + _attr_entity_registry_enabled_default = False def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format): """Initialize the OpenTherm Gateway sensor.""" @@ -58,37 +59,39 @@ def __init__(self, gw_dev, var, source, device_class, unit, friendly_name_format self._gateway = gw_dev self._var = var self._source = source - self._value = None - self._device_class = device_class - self._unit = unit + self._attr_device_class = device_class + self._attr_native_unit_of_measurement = unit if TRANSLATE_SOURCE[source] is not None: friendly_name_format = ( f"{friendly_name_format} ({TRANSLATE_SOURCE[source]})" ) - self._friendly_name = friendly_name_format.format(gw_dev.name) + self._attr_name = friendly_name_format.format(gw_dev.name) self._unsub_updates = None + self._attr_unique_id = f"{gw_dev.gw_id}-{source}-{var}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, gw_dev.gw_id)}, + manufacturer="Schelte Bron", + model="OpenTherm Gateway", + name=gw_dev.name, + sw_version=gw_dev.gw_version, + ) async def async_added_to_hass(self) -> None: """Subscribe to updates from the component.""" - _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Added OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates = async_dispatcher_connect( self.hass, self._gateway.update_signal, self.receive_report ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from updates from the component.""" - _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._friendly_name) + _LOGGER.debug("Removing OpenTherm Gateway sensor %s", self._attr_name) self._unsub_updates() @property def available(self): """Return availability of the sensor.""" - return self._value is not None - - @property - def entity_registry_enabled_default(self): - """Disable sensors by default.""" - return False + return self._attr_native_value is not None @callback def receive_report(self, status): @@ -96,41 +99,5 @@ def receive_report(self, status): value = status[self._source].get(self._var) if isinstance(value, float): value = f"{value:2.1f}" - self._value = value + self._attr_native_value = value self.async_write_ha_state() - - @property - def name(self): - """Return the friendly name of the sensor.""" - return self._friendly_name - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._gateway.gw_id)}, - manufacturer="Schelte Bron", - model="OpenTherm Gateway", - name=self._gateway.name, - sw_version=self._gateway.gw_version, - ) - - @property - def unique_id(self): - """Return a unique ID.""" - return f"{self._gateway.gw_id}-{self._source}-{self._var}" - - @property - def device_class(self): - """Return the device class.""" - return self._device_class - - @property - def native_value(self): - """Return the state of the device.""" - return self._value - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement.""" - return self._unit From e8c4ddf05cdba5c1487fde3731833f4c2e9d84c9 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 7 Sep 2023 13:22:24 -0400 Subject: [PATCH 208/640] Bump ZHA dependencies (#99855) --- 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 7352487a318732..cce223fac1100b 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -21,7 +21,7 @@ "universal_silabs_flasher" ], "requirements": [ - "bellows==0.36.2", + "bellows==0.36.3", "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", diff --git a/requirements_all.txt b/requirements_all.txt index 89c4cff5d3de96..3b2bdf99c66a07 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -510,7 +510,7 @@ beautifulsoup4==4.12.2 # beewi-smartclim==0.0.10 # homeassistant.components.zha -bellows==0.36.2 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6d59aa4493f4df..bb37e4285b83d0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -431,7 +431,7 @@ base36==0.1.1 beautifulsoup4==4.12.2 # homeassistant.components.zha -bellows==0.36.2 +bellows==0.36.3 # homeassistant.components.bmw_connected_drive bimmer-connected==0.14.0 From dcd00546ba1ff49534fdd3db4f0d653f9e786c12 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:47:56 +0200 Subject: [PATCH 209/640] Use shorthand attributes in Sonarr (#99844) --- homeassistant/components/sonarr/entity.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sonarr/entity.py b/homeassistant/components/sonarr/entity.py index d73b9d852c86d5..6231ca3903ae40 100644 --- a/homeassistant/components/sonarr/entity.py +++ b/homeassistant/components/sonarr/entity.py @@ -24,15 +24,11 @@ def __init__( self.coordinator = coordinator self.entity_description = description self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}" - - @property - def device_info(self) -> DeviceInfo: - """Return device information about the application.""" - return DeviceInfo( - configuration_url=self.coordinator.host_configuration.base_url, + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.host_configuration.base_url, entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self.coordinator.config_entry.entry_id)}, + identifiers={(DOMAIN, coordinator.config_entry.entry_id)}, manufacturer=DEFAULT_NAME, name=DEFAULT_NAME, - sw_version=self.coordinator.system_version, + sw_version=coordinator.system_version, ) From a00cbe2677bf6a3927be3e35ce876a3820584ec0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:49:18 +0200 Subject: [PATCH 210/640] Move shorthand attributes out of Snooz constructor (#99842) --- homeassistant/components/snooz/fan.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/snooz/fan.py b/homeassistant/components/snooz/fan.py index c5b3e5b5b696cd..5cb80cb4189409 100644 --- a/homeassistant/components/snooz/fan.py +++ b/homeassistant/components/snooz/fan.py @@ -74,15 +74,15 @@ class SnoozFan(FanEntity, RestoreEntity): _attr_has_entity_name = True _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_should_poll = False + _is_on: bool | None = None + _percentage: int | None = None def __init__(self, data: SnoozConfigurationData) -> None: """Initialize a Snooz fan entity.""" self._device = data.device self._attr_unique_id = data.device.address - self._attr_supported_features = FanEntityFeature.SET_SPEED - self._attr_should_poll = False - self._is_on: bool | None = None - self._percentage: int | None = None self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, data.device.address)}) @callback From 02e077daab5c7c01548d4db590889f5baa04e47a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:51:35 +0200 Subject: [PATCH 211/640] Use shorthand attributes in Ring (#99829) --- homeassistant/components/ring/camera.py | 6 +----- homeassistant/components/ring/entity.py | 16 ++++++---------- homeassistant/components/ring/light.py | 18 ++++-------------- homeassistant/components/ring/sensor.py | 9 +++------ homeassistant/components/ring/switch.py | 24 +++++------------------- 5 files changed, 19 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/ring/camera.py b/homeassistant/components/ring/camera.py index 0b3f1509b189f8..7f897d172035d1 100644 --- a/homeassistant/components/ring/camera.py +++ b/homeassistant/components/ring/camera.py @@ -60,6 +60,7 @@ def __init__(self, config_entry_id, ffmpeg_manager, device): self._video_url = None self._image = None self._expires_at = dt_util.utcnow() - FORCE_REFRESH_INTERVAL + self._attr_unique_id = device.id async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -91,11 +92,6 @@ def _history_update_callback(self, history_data): self._expires_at = dt_util.utcnow() self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._device.id - @property def extra_state_attributes(self): """Return the state attributes.""" diff --git a/homeassistant/components/ring/entity.py b/homeassistant/components/ring/entity.py index 2b345b3b703ab4..7160d2ef7259bd 100644 --- a/homeassistant/components/ring/entity.py +++ b/homeassistant/components/ring/entity.py @@ -19,6 +19,12 @@ def __init__(self, config_entry_id, device): self._config_entry_id = config_entry_id self._device = device self._attr_extra_state_attributes = {} + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + manufacturer="Ring", + model=device.model, + name=device.name, + ) async def async_added_to_hass(self) -> None: """Register callbacks.""" @@ -37,13 +43,3 @@ def _update_callback(self) -> None: def ring_objects(self): """Return the Ring API objects.""" return self.hass.data[DOMAIN][self._config_entry_id] - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer="Ring", - model=self._device.model, - name=self._device.name, - ) diff --git a/homeassistant/components/ring/light.py b/homeassistant/components/ring/light.py index 2604e557b79b4d..93640e2764e6b5 100644 --- a/homeassistant/components/ring/light.py +++ b/homeassistant/components/ring/light.py @@ -55,8 +55,8 @@ class RingLight(RingEntityMixin, LightEntity): def __init__(self, config_entry_id, device): """Initialize the light.""" super().__init__(config_entry_id, device) - self._unique_id = device.id - self._light_on = device.lights == ON_STATE + self._attr_unique_id = device.id + self._attr_is_on = device.lights == ON_STATE self._no_updates_until = dt_util.utcnow() @callback @@ -65,19 +65,9 @@ def _update_callback(self): if self._no_updates_until > dt_util.utcnow(): return - self._light_on = self._device.lights == ON_STATE + self._attr_is_on = self._device.lights == ON_STATE self.async_write_ha_state() - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id - - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._light_on - def _set_light(self, new_state): """Update light state, and causes Home Assistant to correctly update.""" try: @@ -86,7 +76,7 @@ def _set_light(self, new_state): _LOGGER.error("Time out setting %s light to %s", self.entity_id, new_state) return - self._light_on = new_state == ON_STATE + self._attr_is_on = new_state == ON_STATE self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.async_write_ha_state() diff --git a/homeassistant/components/ring/sensor.py b/homeassistant/components/ring/sensor.py index fbaeb8a4b5b529..af23af07ebab18 100644 --- a/homeassistant/components/ring/sensor.py +++ b/homeassistant/components/ring/sensor.py @@ -68,6 +68,9 @@ def native_value(self): class HealthDataRingSensor(RingSensor): """Ring sensor that relies on health data.""" + # These sensors are data hungry and not useful. Disable by default. + _attr_entity_registry_enabled_default = False + async def async_added_to_hass(self) -> None: """Register callbacks.""" await super().async_added_to_hass() @@ -89,12 +92,6 @@ def _health_update_callback(self, _health_data): """Call update method.""" self.async_write_ha_state() - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # These sensors are data hungry and not useful. Disable by default. - return False - @property def native_value(self): """Return the state of the sensor.""" diff --git a/homeassistant/components/ring/switch.py b/homeassistant/components/ring/switch.py index 43bd303577a13b..7069acd5f0feb1 100644 --- a/homeassistant/components/ring/switch.py +++ b/homeassistant/components/ring/switch.py @@ -50,24 +50,20 @@ def __init__(self, config_entry_id, device, device_type): """Initialize the switch.""" super().__init__(config_entry_id, device) self._device_type = device_type - self._unique_id = f"{self._device.id}-{self._device_type}" - - @property - def unique_id(self): - """Return a unique ID.""" - return self._unique_id + self._attr_unique_id = f"{self._device.id}-{self._device_type}" class SirenSwitch(BaseRingSwitch): """Creates a switch to turn the ring cameras siren on and off.""" _attr_translation_key = "siren" + _attr_icon = SIREN_ICON def __init__(self, config_entry_id, device): """Initialize the switch for a device with a siren.""" super().__init__(config_entry_id, device, "siren") self._no_updates_until = dt_util.utcnow() - self._siren_on = device.siren > 0 + self._attr_is_on = device.siren > 0 @callback def _update_callback(self): @@ -75,7 +71,7 @@ def _update_callback(self): if self._no_updates_until > dt_util.utcnow(): return - self._siren_on = self._device.siren > 0 + self._attr_is_on = self._device.siren > 0 self.async_write_ha_state() def _set_switch(self, new_state): @@ -86,15 +82,10 @@ def _set_switch(self, new_state): _LOGGER.error("Time out setting %s siren to %s", self.entity_id, new_state) return - self._siren_on = new_state > 0 + self._attr_is_on = new_state > 0 self._no_updates_until = dt_util.utcnow() + SKIP_UPDATES_DELAY self.schedule_update_ha_state() - @property - def is_on(self): - """If the switch is currently on or off.""" - return self._siren_on - def turn_on(self, **kwargs: Any) -> None: """Turn the siren on for 30 seconds.""" self._set_switch(1) @@ -102,8 +93,3 @@ def turn_on(self, **kwargs: Any) -> None: def turn_off(self, **kwargs: Any) -> None: """Turn the siren off.""" self._set_switch(0) - - @property - def icon(self): - """Return the icon.""" - return SIREN_ICON From 66d16108be0b92984c0436c76df3813b41ad878a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 19:52:12 +0200 Subject: [PATCH 212/640] Use shorthand attributes in Rainforest eagle (#99825) --- .../components/rainforest_eagle/sensor.py | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/rainforest_eagle/sensor.py b/homeassistant/components/rainforest_eagle/sensor.py index 113cfceb7d635b..987142c6390f45 100644 --- a/homeassistant/components/rainforest_eagle/sensor.py +++ b/homeassistant/components/rainforest_eagle/sensor.py @@ -75,11 +75,13 @@ def __init__(self, coordinator, entity_description): """Initialize the sensor.""" super().__init__(coordinator) self.entity_description = entity_description - - @property - def unique_id(self) -> str | None: - """Return unique ID of entity.""" - return f"{self.coordinator.cloud_id}-${self.coordinator.hardware_address}-{self.entity_description.key}" + self._attr_unique_id = f"{coordinator.cloud_id}-${coordinator.hardware_address}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.cloud_id)}, + manufacturer="Rainforest Automation", + model=coordinator.model, + name=coordinator.model, + ) @property def available(self) -> bool: @@ -90,13 +92,3 @@ def available(self) -> bool: def native_value(self) -> StateType: """Return native value of the sensor.""" return self.coordinator.data.get(self.entity_description.key) - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.coordinator.cloud_id)}, - manufacturer="Rainforest Automation", - model=self.coordinator.model, - name=self.coordinator.model, - ) From 4017473d51631972c272d405a561353a2f09ad36 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 7 Sep 2023 20:00:43 +0200 Subject: [PATCH 213/640] Use str instead of string placeholders in solaredge (#99843) --- homeassistant/components/solaredge/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/solaredge/sensor.py b/homeassistant/components/solaredge/sensor.py index e1ea7960086df5..f2c073c691865c 100644 --- a/homeassistant/components/solaredge/sensor.py +++ b/homeassistant/components/solaredge/sensor.py @@ -353,7 +353,7 @@ def unique_id(self) -> str | None: """Return a unique ID.""" if not self.data_service.site_id: return None - return f"{self.data_service.site_id}" + return str(self.data_service.site_id) class SolarEdgeInventorySensor(SolarEdgeSensorEntity): From c68d96cf092a9f5e1b71805173dcb5d420ae628b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 13:25:29 -0500 Subject: [PATCH 214/640] Bump zeroconf to 0.99.0 (#99853) --- 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 117744a2775e03..4f736866fd901c 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.98.0"] + "requirements": ["zeroconf==0.99.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0624415b11c491..452ac9eae281d3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.98.0 +zeroconf==0.99.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 3b2bdf99c66a07..09beeec41943f0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.99.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb37e4285b83d0..91b151904d55c2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.98.0 +zeroconf==0.99.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 54bd7c9af0ce4a9086986590bbe8ae70e6917273 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 13:27:29 -0500 Subject: [PATCH 215/640] Bump dbus-fast to 1.95.2 (#99852) --- 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 bcb371971a6f45..4231e03c2efa72 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -19,6 +19,6 @@ "bluetooth-adapters==0.16.0", "bluetooth-auto-recovery==1.2.1", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.95.0" + "dbus-fast==1.95.2" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 452ac9eae281d3..6b5ed1dc9f90d3 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.95.0 +dbus-fast==1.95.2 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 09beeec41943f0..db110087d19d83 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.0 +dbus-fast==1.95.2 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91b151904d55c2..7817932dedffad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.0 +dbus-fast==1.95.2 # homeassistant.components.debugpy debugpy==1.6.7 From 0dc8e8dabef34ebea5fa9e5434d2a08301ffcf34 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Thu, 7 Sep 2023 20:34:23 +0200 Subject: [PATCH 216/640] Add device class and UoM in Sensibo Number entities (#99861) * device class and uom number platform * icons --- homeassistant/components/sensibo/number.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/sensibo/number.py b/homeassistant/components/sensibo/number.py index 94765a17a4d5dc..d4e268ea44d4d3 100644 --- a/homeassistant/components/sensibo/number.py +++ b/homeassistant/components/sensibo/number.py @@ -7,9 +7,13 @@ from pysensibo.model import SensiboDevice -from homeassistant.components.number import NumberEntity, NumberEntityDescription +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -39,8 +43,9 @@ class SensiboNumberEntityDescription( SensiboNumberEntityDescription( key="calibration_temp", translation_key="calibration_temperature", + device_class=NumberDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, remote_key="temperature", - icon="mdi:thermometer", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, @@ -51,8 +56,9 @@ class SensiboNumberEntityDescription( SensiboNumberEntityDescription( key="calibration_hum", translation_key="calibration_humidity", + device_class=NumberDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, remote_key="humidity", - icon="mdi:water", entity_category=EntityCategory.CONFIG, entity_registry_enabled_default=False, native_min_value=-10, From 1c27a0339d97af2da570fac441f62d6e12f4e6de Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 7 Sep 2023 20:37:14 +0200 Subject: [PATCH 217/640] Renson fan (#94495) * Add fan feature * Changed order of platform * Use super()._handle_coordinator_update() * format file * Set _attr_has_entity_name * Cleanup Fan code * Refresh after setting ventilation speed + translation * remove unused translation key --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/fan.py | 118 ++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 homeassistant/components/renson/fan.py diff --git a/.coveragerc b/.coveragerc index c72400392b788e..d9cb511e86e4e6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1009,6 +1009,7 @@ omit = homeassistant/components/renson/const.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 86dfdc1f18bd79..dbc0468a11adef 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -21,6 +21,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.FAN, Platform.SENSOR, ] diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py new file mode 100644 index 00000000000000..0fe639d40ec8f2 --- /dev/null +++ b/homeassistant/components/renson/fan.py @@ -0,0 +1,118 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging +import math +from typing import Any + +from renson_endura_delta.field_enum import CURRENT_LEVEL_FIELD, DataType +from renson_endura_delta.renson import Level, RensonVentilation + +from homeassistant.components.fan import FanEntity, FanEntityFeature +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util.percentage import ( + int_states_in_range, + percentage_to_ranged_value, + ranged_value_to_percentage, +) + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + +CMD_MAPPING = { + 0: Level.HOLIDAY, + 1: Level.LEVEL1, + 2: Level.LEVEL2, + 3: Level.LEVEL3, + 4: Level.LEVEL4, +} + +SPEED_MAPPING = { + Level.OFF.value: 0, + Level.HOLIDAY.value: 0, + Level.LEVEL1.value: 1, + Level.LEVEL2.value: 2, + Level.LEVEL3.value: 3, + Level.LEVEL4.value: 4, +} + + +SPEED_RANGE: tuple[float, float] = (1, 4) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson fan platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonFan(api, coordinator)]) + + +class RensonFan(RensonEntity, FanEntity): + """Representation of the Renson fan platform.""" + + _attr_icon = "mdi:air-conditioner" + _attr_has_entity_name = True + _attr_name = None + _attr_supported_features = FanEntityFeature.SET_SPEED + + def __init__(self, api: RensonVentilation, coordinator: RensonCoordinator) -> None: + """Initialize the Renson fan.""" + super().__init__("fan", api, coordinator) + self._attr_speed_count = int_states_in_range(SPEED_RANGE) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + level = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, CURRENT_LEVEL_FIELD.name), + DataType.LEVEL, + ) + + self._attr_percentage = ranged_value_to_percentage( + SPEED_RANGE, SPEED_MAPPING[level] + ) + + super()._handle_coordinator_update() + + async def async_turn_on( + self, + percentage: int | None = None, + preset_mode: str | None = None, + **kwargs: Any, + ) -> None: + """Turn on the fan.""" + if percentage is None: + percentage = 1 + + await self.async_set_percentage(percentage) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the fan (to away).""" + await self.async_set_percentage(0) + + async def async_set_percentage(self, percentage: int) -> None: + """Set fan speed percentage.""" + _LOGGER.debug("Changing fan speed percentage to %s", percentage) + + if percentage == 0: + cmd = Level.HOLIDAY + else: + speed = math.ceil(percentage_to_ranged_value(SPEED_RANGE, percentage)) + cmd = CMD_MAPPING[speed] + + await self.hass.async_add_executor_job(self.api.set_manual_level, cmd) + + await self.coordinator.async_request_refresh() From 77180a73b7bbc6aa2b1d9b7b063a1cc7be38763c Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 7 Sep 2023 20:56:00 +0200 Subject: [PATCH 218/640] Modbus scale parameter cuts decimals (#99758) --- .../components/modbus/base_platform.py | 2 ++ tests/components/modbus/test_sensor.py | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index b71f8c2021584c..672250790da028 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -160,6 +160,8 @@ def __init__(self, hub: ModbusHub, config: dict) -> None: self._structure: str = config[CONF_STRUCTURE] self._precision = config[CONF_PRECISION] self._scale = config[CONF_SCALE] + if self._scale < 1 and not self._precision: + self._precision = 2 self._offset = config[CONF_OFFSET] self._slave_count = config.get(CONF_SLAVE_COUNT, 0) self._slave_size = self._count = config[CONF_COUNT] diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index a746bcda3ba53b..551398c898bfb1 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -596,6 +596,38 @@ async def test_config_wrong_struct_sensor( False, "1.23", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 10, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593750", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + CONF_PRECISION: 2, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 0.01, + CONF_OFFSET: 0, + }, + [0x00AB, 0xCDEF], + False, + "112593.75", + ), ], ) async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: From 4ce9c1f304739861a4289f92d84172719ff3e69d Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 7 Sep 2023 15:27:41 -0400 Subject: [PATCH 219/640] Add Diagnostic platform to Aladdin Connect (#99682) * Add diagnostics platform * Add diagnostic platform * Add raw data to diagnostics * Remove config data bump aioaladdinconnect, use new doors property for diag * remove unnecessary component config refactor diag output --- .../components/aladdin_connect/diagnostics.py | 29 ++++++++++++++ .../components/aladdin_connect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/aladdin_connect/conftest.py | 6 ++- .../snapshots/test_diagnostics.ambr | 20 ++++++++++ .../aladdin_connect/test_diagnostics.py | 40 +++++++++++++++++++ 7 files changed, 97 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/aladdin_connect/diagnostics.py create mode 100644 tests/components/aladdin_connect/snapshots/test_diagnostics.ambr create mode 100644 tests/components/aladdin_connect/test_diagnostics.py diff --git a/homeassistant/components/aladdin_connect/diagnostics.py b/homeassistant/components/aladdin_connect/diagnostics.py new file mode 100644 index 00000000000000..c49d321631e2f7 --- /dev/null +++ b/homeassistant/components/aladdin_connect/diagnostics.py @@ -0,0 +1,29 @@ +"""Diagnostics support for Aladdin Connect.""" +from __future__ import annotations + +from typing import Any + +from AIOAladdinConnect import AladdinConnectClient + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN + +TO_REDACT = {"serial", "device_id"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + acc: AladdinConnectClient = hass.data[DOMAIN][config_entry.entry_id] + + diagnostics_data = { + "doors": async_redact_data(acc.doors, TO_REDACT), + } + + return diagnostics_data diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 3f31a833f1aa14..83f8e0167e868f 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "iot_class": "cloud_polling", "loggers": ["aladdin_connect"], - "requirements": ["AIOAladdinConnect==0.1.57"] + "requirements": ["AIOAladdinConnect==0.1.58"] } diff --git a/requirements_all.txt b/requirements_all.txt index db110087d19d83..024443e8cf5517 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -5,7 +5,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7817932dedffad..4c468519570355 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -7,7 +7,7 @@ AEMET-OpenData==0.4.4 # homeassistant.components.aladdin_connect -AIOAladdinConnect==0.1.57 +AIOAladdinConnect==0.1.58 # homeassistant.components.honeywell AIOSomecomfort==0.0.17 diff --git a/tests/components/aladdin_connect/conftest.py b/tests/components/aladdin_connect/conftest.py index 250548e7ef20dd..3f5fc4f8f976ee 100644 --- a/tests/components/aladdin_connect/conftest.py +++ b/tests/components/aladdin_connect/conftest.py @@ -12,6 +12,10 @@ "link_status": "Connected", "serial": "12345", "model": "02", + "rssi": -67, + "ble_strength": 0, + "vendor": "GENIE", + "battery_level": 0, } @@ -35,7 +39,7 @@ def fixture_mock_aladdinconnect_api(): mock_opener.async_get_ble_strength = AsyncMock(return_value="-45") mock_opener.get_ble_strength.return_value = "-45" mock_opener.get_doors = AsyncMock(return_value=[DEVICE_CONFIG_OPEN]) - + mock_opener.doors = [DEVICE_CONFIG_OPEN] mock_opener.register_callback = mock.Mock(return_value=True) mock_opener.open_door = AsyncMock(return_value=True) mock_opener.close_door = AsyncMock(return_value=True) diff --git a/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..8f96567a49f1b3 --- /dev/null +++ b/tests/components/aladdin_connect/snapshots/test_diagnostics.ambr @@ -0,0 +1,20 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'doors': list([ + dict({ + 'battery_level': 0, + 'ble_strength': 0, + 'device_id': '**REDACTED**', + 'door_number': 1, + 'link_status': 'Connected', + 'model': '02', + 'name': 'home', + 'rssi': -67, + 'serial': '**REDACTED**', + 'status': 'open', + 'vendor': 'GENIE', + }), + ]), + }) +# --- diff --git a/tests/components/aladdin_connect/test_diagnostics.py b/tests/components/aladdin_connect/test_diagnostics.py new file mode 100644 index 00000000000000..4d5fe9037981db --- /dev/null +++ b/tests/components/aladdin_connect/test_diagnostics.py @@ -0,0 +1,40 @@ +"""Test AccuWeather diagnostics.""" +from unittest.mock import MagicMock, patch + +from syrupy import SnapshotAssertion + +from homeassistant.components.aladdin_connect.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + mock_aladdinconnect_api: MagicMock, +) -> None: + """Test config entry diagnostics.""" + + config_entry = MockConfigEntry( + domain=DOMAIN, + data=YAML_CONFIG, + unique_id="test-id", + ) + config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.aladdin_connect.AladdinConnectClient", + return_value=mock_aladdinconnect_api, + ): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From cd8426152f6c41aa6a003b5b50404b65d6647d7e Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 7 Sep 2023 21:49:03 +0200 Subject: [PATCH 220/640] Fix NOAA tides warnings (#99856) --- homeassistant/components/noaa_tides/sensor.py | 49 ++++++++++++++----- 1 file changed, 37 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/noaa_tides/sensor.py b/homeassistant/components/noaa_tides/sensor.py index 7f3260c7635abc..a83f18fd6ca22a 100644 --- a/homeassistant/components/noaa_tides/sensor.py +++ b/homeassistant/components/noaa_tides/sensor.py @@ -3,6 +3,7 @@ from datetime import datetime, timedelta import logging +from typing import TYPE_CHECKING, Any, Literal, TypedDict import noaa_coops as coops import requests @@ -17,6 +18,9 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.unit_system import METRIC_SYSTEM +if TYPE_CHECKING: + from pandas import Timestamp + _LOGGER = logging.getLogger(__name__) CONF_STATION_ID = "station_id" @@ -76,40 +80,56 @@ def setup_platform( add_entities([noaa_sensor], True) +class NOAATidesData(TypedDict): + """Representation of a single tide.""" + + time_stamp: list[Timestamp] + hi_lo: list[Literal["L"] | Literal["H"]] + predicted_wl: list[float] + + class NOAATidesAndCurrentsSensor(SensorEntity): """Representation of a NOAA Tides and Currents sensor.""" _attr_attribution = "Data provided by NOAA" - def __init__(self, name, station_id, timezone, unit_system, station): + def __init__(self, name, station_id, timezone, unit_system, station) -> None: """Initialize the sensor.""" self._name = name self._station_id = station_id self._timezone = timezone self._unit_system = unit_system self._station = station - self.data = None + self.data: NOAATidesData | None = None @property - def name(self): + def name(self) -> str: """Return the name of the sensor.""" return self._name @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of this device.""" - attr = {} + attr: dict[str, Any] = {} if self.data is None: return attr if self.data["hi_lo"][1] == "H": - attr["high_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][1] - attr["low_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][2] elif self.data["hi_lo"][1] == "L": - attr["low_tide_time"] = self.data.index[1].strftime("%Y-%m-%dT%H:%M") + attr["low_tide_time"] = self.data["time_stamp"][1].strftime( + "%Y-%m-%dT%H:%M" + ) attr["low_tide_height"] = self.data["predicted_wl"][1] - attr["high_tide_time"] = self.data.index[2].strftime("%Y-%m-%dT%H:%M") + attr["high_tide_time"] = self.data["time_stamp"][2].strftime( + "%Y-%m-%dT%H:%M" + ) attr["high_tide_height"] = self.data["predicted_wl"][2] return attr @@ -118,7 +138,7 @@ def native_value(self): """Return the state of the device.""" if self.data is None: return None - api_time = self.data.index[0] + api_time = self.data["time_stamp"][0] if self.data["hi_lo"][0] == "H": tidetime = api_time.strftime("%-I:%M %p") return f"High tide at {tidetime}" @@ -142,8 +162,13 @@ def update(self) -> None: units=self._unit_system, time_zone=self._timezone, ) - self.data = df_predictions.head() - _LOGGER.debug("Data = %s", self.data) + api_data = df_predictions.head() + self.data = NOAATidesData( + time_stamp=list(api_data.index), + hi_lo=list(api_data["hi_lo"].values), + predicted_wl=list(api_data["predicted_wl"].values), + ) + _LOGGER.debug("Data = %s", api_data) _LOGGER.debug( "Recent Tide data queried with start time set to %s", begin.strftime("%m-%d-%Y %H:%M"), From 9d5595fd7d852e13829e85cf04cdf020dcb4a136 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 7 Sep 2023 16:08:53 -0500 Subject: [PATCH 221/640] Bump zeroconf to 0.102.0 (#99875) --- 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 4f736866fd901c..e97c430d35db39 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.99.0"] + "requirements": ["zeroconf==0.102.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 6b5ed1dc9f90d3..629e654bb7bf0b 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.99.0 +zeroconf==0.102.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 024443e8cf5517..7ccc7b0eb9d64d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.99.0 +zeroconf==0.102.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4c468519570355..776fe09d99fa0e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2040,7 +2040,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.99.0 +zeroconf==0.102.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From a82cd48282ec43e043697414aa31587b37bdd0eb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Fri, 8 Sep 2023 00:32:15 +0200 Subject: [PATCH 222/640] Bump aiovodafone to 0.1.0 (#99851) * bump aiovodafone to 0.1.0 * fix tests --- homeassistant/components/vodafone_station/coordinator.py | 4 ++-- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/vodafone_station/test_config_flow.py | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index b79acac9ce99b5..58079180bf8cca 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -112,9 +112,9 @@ async def _async_update_data(self) -> UpdateCoordinatorDataType: dev_info, utc_point_in_time ), ) - for dev_info in (await self.api.get_all_devices()).values() + for dev_info in (await self.api.get_devices_data()).values() } - data_sensors = await self.api.get_user_data() + data_sensors = await self.api.get_sensor_data() await self.api.logout() return UpdateCoordinatorDataType(data_devices, data_sensors) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 7069629ca2e206..5470cdd684c40c 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.0.6"] + "requirements": ["aiovodafone==0.1.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7ccc7b0eb9d64d..a9ff02dfac557e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -370,7 +370,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.1.0 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 776fe09d99fa0e..74c38e5d1629d7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.0.6 +aiovodafone==0.1.0 # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/vodafone_station/test_config_flow.py b/tests/components/vodafone_station/test_config_flow.py index 03a1198288d825..3d2ef0cf568621 100644 --- a/tests/components/vodafone_station/test_config_flow.py +++ b/tests/components/vodafone_station/test_config_flow.py @@ -78,7 +78,7 @@ 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_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.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|;", @@ -191,7 +191,7 @@ 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_all_devices", + "homeassistant.components.vodafone_station.config_flow.VodafoneStationApi.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|;", From 5a66aac330e573c2293fea515298590bcafd4bed Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:02:03 +0200 Subject: [PATCH 223/640] Use shorthand attributes in Telldus live (#99887) --- homeassistant/components/tellduslive/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index e15f89888b1717..06b505d95742d1 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -142,6 +142,7 @@ 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: @@ -189,8 +190,3 @@ def native_value(self): 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 c2b119bfaf255a042770bd3d85f66e50145b845c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:07:15 +0200 Subject: [PATCH 224/640] Use shorthand attributes in Tp-link Omada (#99889) --- homeassistant/components/tplink_omada/entity.py | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tplink_omada/entity.py b/homeassistant/components/tplink_omada/entity.py index bb330ef417a512..5008b7e4b18cb2 100644 --- a/homeassistant/components/tplink_omada/entity.py +++ b/homeassistant/components/tplink_omada/entity.py @@ -20,14 +20,10 @@ def __init__(self, coordinator: OmadaCoordinator[T], device: OmadaDevice) -> Non """Initialize the device.""" super().__init__(coordinator) self.device = device - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, (self.device.mac))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, device.mac)}, manufacturer="TP-Link", - model=self.device.model_display_name, - name=self.device.name, + model=device.model_display_name, + name=device.name, ) From 56f05bee91b03eecf710602215b163eccfa883ad Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:34 +0200 Subject: [PATCH 225/640] Use shorthand attributes in Tradfri (#99890) --- .../components/tradfri/base_class.py | 24 +++++++---------- homeassistant/components/tradfri/fan.py | 26 ++++++------------- 2 files changed, 18 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/tradfri/base_class.py b/homeassistant/components/tradfri/base_class.py index d186e19a2c8e87..416eb175d31df1 100644 --- a/homeassistant/components/tradfri/base_class.py +++ b/homeassistant/components/tradfri/base_class.py @@ -55,7 +55,16 @@ def __init__( self._device_id = self._device.id self._api = handle_error(api) - self._attr_unique_id = f"{self._gateway_id}-{self._device.id}" + info = self._device.device_info + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=info.manufacturer, + model=info.model_number, + name=self._device.name, + sw_version=info.firmware_version, + via_device=(DOMAIN, gateway_id), + ) + self._attr_unique_id = f"{gateway_id}-{self._device_id}" @abstractmethod @callback @@ -71,19 +80,6 @@ def _handle_coordinator_update(self) -> None: self._refresh() super()._handle_coordinator_update() - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - info = self._device.device_info - return DeviceInfo( - identifiers={(DOMAIN, self._device.id)}, - manufacturer=info.manufacturer, - model=info.model_number, - name=self._device.name, - sw_version=info.firmware_version, - via_device=(DOMAIN, self._gateway_id), - ) - @property def available(self) -> bool: """Return if entity is available.""" diff --git a/homeassistant/components/tradfri/fan.py b/homeassistant/components/tradfri/fan.py index a26dfa1d9a099c..c41b24a26473b7 100644 --- a/homeassistant/components/tradfri/fan.py +++ b/homeassistant/components/tradfri/fan.py @@ -56,6 +56,14 @@ class TradfriAirPurifierFan(TradfriBaseEntity, FanEntity): _attr_name = None _attr_supported_features = FanEntityFeature.PRESET_MODE | FanEntityFeature.SET_SPEED + _attr_preset_modes = [ATTR_AUTO] + # These are the steps: + # 0 = Off + # 1 = Preset: Auto mode + # 2 = Min + # ... with step size 1 + # 50 = Max + _attr_speed_count = ATTR_MAX_FAN_STEPS def __init__( self, @@ -77,19 +85,6 @@ def _refresh(self) -> None: """Refresh the device.""" self._device_data = self.coordinator.data.air_purifier_control.air_purifiers[0] - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports. - - These are the steps: - 0 = Off - 1 = Preset: Auto mode - 2 = Min - ... with step size 1 - 50 = Max - """ - return ATTR_MAX_FAN_STEPS - @property def is_on(self) -> bool: """Return true if switch is on.""" @@ -97,11 +92,6 @@ def is_on(self) -> bool: return False return cast(bool, self._device_data.state) - @property - def preset_modes(self) -> list[str] | None: - """Return a list of available preset modes.""" - return [ATTR_AUTO] - @property def percentage(self) -> int | None: """Return the current speed percentage.""" From a3d6c6192edfb9742dc47915d4695e3713799477 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:49 +0200 Subject: [PATCH 226/640] Use shorthand attributes in Tado (#99886) --- homeassistant/components/tado/climate.py | 39 +++++-------------- homeassistant/components/tado/entity.py | 39 ++++++------------- homeassistant/components/tado/water_heater.py | 19 ++------- 3 files changed, 24 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/tado/climate.py b/homeassistant/components/tado/climate.py index 36a2ab671c9163..1193638c10e0a0 100644 --- a/homeassistant/components/tado/climate.py +++ b/homeassistant/components/tado/climate.py @@ -219,6 +219,8 @@ class TadoClimate(TadoZoneEntity, ClimateEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_name = None + _attr_translation_key = DOMAIN + _available = False def __init__( self, @@ -245,22 +247,22 @@ def __init__( self.zone_type = zone_type self._attr_unique_id = f"{zone_type} {zone_id} {tado.home_id}" - self._attr_temperature_unit = UnitOfTemperature.CELSIUS - - self._attr_translation_key = DOMAIN self._device_info = device_info self._device_id = self._device_info["shortSerialNo"] self._ac_device = zone_type == TYPE_AIR_CONDITIONING - self._supported_hvac_modes = supported_hvac_modes - self._supported_fan_modes = supported_fan_modes + self._attr_hvac_modes = supported_hvac_modes + self._attr_fan_modes = supported_fan_modes self._attr_supported_features = support_flags - self._available = False - self._cur_temp = None self._cur_humidity = None + if self.supported_features & ClimateEntityFeature.SWING_MODE: + self._attr_swing_modes = [ + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], + TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], + ] self._heat_min_temp = heat_min_temp self._heat_max_temp = heat_max_temp @@ -324,14 +326,6 @@ def hvac_mode(self) -> HVACMode: """ return TADO_TO_HA_HVAC_MODE_MAP.get(self._current_tado_hvac_mode, HVACMode.OFF) - @property - def hvac_modes(self) -> list[HVACMode]: - """Return the list of available hvac operation modes. - - Need to be a subset of HVAC_MODES. - """ - return self._supported_hvac_modes - @property def hvac_action(self) -> HVACAction: """Return the current running hvac operation if supported. @@ -349,11 +343,6 @@ def fan_mode(self): return TADO_TO_HA_FAN_MODE_MAP.get(self._current_tado_fan_speed, FAN_AUTO) return None - @property - def fan_modes(self): - """List of available fan modes.""" - return self._supported_fan_modes - def set_fan_mode(self, fan_mode: str) -> None: """Turn fan on/off.""" self._control_hvac(fan_mode=HA_TO_TADO_FAN_MODE_MAP[fan_mode]) @@ -474,16 +463,6 @@ def swing_mode(self): """Active swing mode for the device.""" return TADO_TO_HA_SWING_MODE_MAP[self._current_tado_swing_mode] - @property - def swing_modes(self): - """Swing modes for the device.""" - if self.supported_features & ClimateEntityFeature.SWING_MODE: - return [ - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_ON], - TADO_TO_HA_SWING_MODE_MAP[TADO_SWING_OFF], - ] - return None - @property def extra_state_attributes(self): """Return temperature offset.""" diff --git a/homeassistant/components/tado/entity.py b/homeassistant/components/tado/entity.py index cfc9e5b1e6ef65..532d784b1908da 100644 --- a/homeassistant/components/tado/entity.py +++ b/homeassistant/components/tado/entity.py @@ -17,18 +17,14 @@ def __init__(self, device_info): self._device_info = device_info self.device_name = device_info["serialNo"] self.device_id = device_info["shortSerialNo"] - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url=f"https://app.tado.com/en/main/settings/rooms-and-devices/device/{self.device_name}", identifiers={(DOMAIN, self.device_id)}, name=self.device_name, manufacturer=DEFAULT_NAME, - sw_version=self._device_info["currentFwVersion"], - model=self._device_info["deviceType"], - via_device=(DOMAIN, self._device_info["serialNo"]), + sw_version=device_info["currentFwVersion"], + model=device_info["deviceType"], + via_device=(DOMAIN, device_info["serialNo"]), ) @@ -43,16 +39,12 @@ def __init__(self, tado): super().__init__() self.home_name = tado.home_name self.home_id = tado.home_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( + self._attr_device_info = DeviceInfo( configuration_url="https://app.tado.com", - identifiers={(DOMAIN, self.home_id)}, + identifiers={(DOMAIN, tado.home_id)}, manufacturer=DEFAULT_NAME, model=TADO_HOME, - name=self.home_name, + name=tado.home_name, ) @@ -65,20 +57,13 @@ class TadoZoneEntity(Entity): def __init__(self, zone_name, home_id, zone_id): """Initialize a Tado zone.""" super().__init__() - self._device_zone_id = f"{home_id}_{zone_id}" self.zone_name = zone_name self.zone_id = zone_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device_info of the device.""" - return DeviceInfo( - configuration_url=( - f"https://app.tado.com/en/main/home/zoneV2/{self.zone_id}" - ), - identifiers={(DOMAIN, self._device_zone_id)}, - name=self.zone_name, + self._attr_device_info = DeviceInfo( + configuration_url=(f"https://app.tado.com/en/main/home/zoneV2/{zone_id}"), + identifiers={(DOMAIN, f"{home_id}_{zone_id}")}, + name=zone_name, manufacturer=DEFAULT_NAME, model=TADO_ZONE, - suggested_area=self.zone_name, + suggested_area=zone_name, ) diff --git a/homeassistant/components/tado/water_heater.py b/homeassistant/components/tado/water_heater.py index 6d17c85c9811e5..b7e68bbb1003db 100644 --- a/homeassistant/components/tado/water_heater.py +++ b/homeassistant/components/tado/water_heater.py @@ -120,6 +120,8 @@ class TadoWaterHeater(TadoZoneEntity, WaterHeaterEntity): """Representation of a Tado water heater.""" _attr_name = None + _attr_operation_list = OPERATION_MODES + _attr_temperature_unit = UnitOfTemperature.CELSIUS def __init__( self, @@ -136,7 +138,7 @@ def __init__( super().__init__(zone_name, tado.home_id, zone_id) self.zone_id = zone_id - self._unique_id = f"{zone_id} {tado.home_id}" + self._attr_unique_id = f"{zone_id} {tado.home_id}" self._device_is_active = False @@ -168,11 +170,6 @@ async def async_added_to_hass(self) -> None: ) self._async_update_data() - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - @property def current_operation(self): """Return current readable operation mode.""" @@ -188,16 +185,6 @@ def is_away_mode_on(self): """Return true if away mode is on.""" return self._tado_zone_data.is_away - @property - def operation_list(self): - """Return the list of available operation modes (readable).""" - return OPERATION_MODES - - @property - def temperature_unit(self): - """Return the unit of measurement used by the platform.""" - return UnitOfTemperature.CELSIUS - @property def min_temp(self): """Return the minimum temperature.""" From 9e8a8012dfcfca34d80d475324a91fffe17127d6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:15:58 +0200 Subject: [PATCH 227/640] Use shorthand attributes in Syncthru (#99884) --- homeassistant/components/syncthru/sensor.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/syncthru/sensor.py b/homeassistant/components/syncthru/sensor.py index c2ad159fb21093..f651556bddb5c5 100644 --- a/homeassistant/components/syncthru/sensor.py +++ b/homeassistant/components/syncthru/sensor.py @@ -109,6 +109,8 @@ class SyncThruMainSensor(SyncThruSensor): the displayed current status message. """ + _attr_entity_registry_enabled_default = False + def __init__(self, coordinator: DataUpdateCoordinator[SyncThru], name: str) -> None: """Initialize the sensor.""" super().__init__(coordinator, name) @@ -126,11 +128,6 @@ def extra_state_attributes(self): "display_text": self.syncthru.device_status_details(), } - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False - class SyncThruTonerSensor(SyncThruSensor): """Implementation of a Samsung Printer toner sensor platform.""" From 4e826f170452129e30af713323eac347d8f08f97 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:16:08 +0200 Subject: [PATCH 228/640] Use shorthand attributes in Syncthing (#99883) --- homeassistant/components/syncthing/sensor.py | 31 ++++++-------------- 1 file changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/syncthing/sensor.py b/homeassistant/components/syncthing/sensor.py index 0551ae29d2c0bb..c88de91cae076e 100644 --- a/homeassistant/components/syncthing/sensor.py +++ b/homeassistant/components/syncthing/sensor.py @@ -94,19 +94,17 @@ def __init__(self, syncthing, server_id, folder_id, folder_label, version): self._folder_label = folder_label self._state = None self._unsub_timer = None - self._version = version self._short_server_id = server_id.split("-")[0] - - @property - def name(self): - """Return the name of the sensor.""" - return f"{self._short_server_id} {self._folder_id} {self._folder_label}" - - @property - def unique_id(self): - """Return the unique id of the entity.""" - return f"{self._short_server_id}-{self._folder_id}" + self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}" + self._attr_unique_id = f"{self._short_server_id}-{folder_id}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._server_id)}, + manufacturer="Syncthing Team", + name=f"Syncthing ({syncthing.url})", + sw_version=version, + ) @property def native_value(self): @@ -132,17 +130,6 @@ def extra_state_attributes(self): """Return the state attributes.""" return self._state - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, self._server_id)}, - manufacturer="Syncthing Team", - name=f"Syncthing ({self._syncthing.url})", - sw_version=self._version, - ) - async def async_update_status(self): """Request folder status and update state.""" try: From 92628ea068849542af29558951878f2d39cd11f7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:16:35 +0200 Subject: [PATCH 229/640] Use shorthand attributes in Starline (#99882) --- homeassistant/components/starline/entity.py | 12 ++---------- homeassistant/components/starline/switch.py | 7 ++----- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/starline/entity.py b/homeassistant/components/starline/entity.py index 7eee5e7a7f8a98..27be5e2aaced42 100644 --- a/homeassistant/components/starline/entity.py +++ b/homeassistant/components/starline/entity.py @@ -21,6 +21,8 @@ def __init__( self._account = account self._device = device self._key = key + self._attr_unique_id = f"starline-{key}-{device.device_id}" + self._attr_device_info = account.device_info(device) self._unsubscribe_api: Callable | None = None @property @@ -28,16 +30,6 @@ def available(self): """Return True if entity is available.""" return self._account.api.available - @property - def unique_id(self): - """Return the unique ID of the entity.""" - return f"starline-{self._key}-{self._device.device_id}" - - @property - def device_info(self): - """Return the device info.""" - return self._account.device_info(self._device) - def update(self): """Read new state data.""" self.schedule_update_ha_state() diff --git a/homeassistant/components/starline/switch.py b/homeassistant/components/starline/switch.py index b254fa8133fc92..ebe27e29e8c230 100644 --- a/homeassistant/components/starline/switch.py +++ b/homeassistant/components/starline/switch.py @@ -77,6 +77,8 @@ class StarlineSwitch(StarlineEntity, SwitchEntity): entity_description: StarlineSwitchEntityDescription + _attr_assumed_state = True + def __init__( self, account: StarlineAccount, @@ -108,11 +110,6 @@ def icon(self): else self.entity_description.icon_off ) - @property - def assumed_state(self): - """Return True if unable to access real state of the entity.""" - return True - @property def is_on(self): """Return True if entity is on.""" From 432894a4015d45a570505d6c3d8bbf14b4b1381d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 01:20:22 +0200 Subject: [PATCH 230/640] Use shorthand attributes in SRP Energy (#99881) --- homeassistant/components/srp_energy/sensor.py | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py index a7f0f97b636587..f6bd470df8a785 100644 --- a/homeassistant/components/srp_energy/sensor.py +++ b/homeassistant/components/srp_energy/sensor.py @@ -40,12 +40,7 @@ def __init__( """Initialize the SrpEntity class.""" super().__init__(coordinator) self._attr_unique_id = f"{config_entry.entry_id}_total_usage" - self._name = SENSOR_NAME - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{DEFAULT_NAME} {self._name}" + self._attr_name = f"{DEFAULT_NAME} {SENSOR_NAME}" @property def native_value(self) -> float: From b76ba002e2611bb1e51a218d80f70ab7b5af0fc7 Mon Sep 17 00:00:00 2001 From: lymanepp <4195527+lymanepp@users.noreply.github.com> Date: Thu, 7 Sep 2023 22:12:18 -0400 Subject: [PATCH 231/640] Fix missing dew point and humidity in tomorrowio forecasts (#99793) * Fix missing dew point and humidity in tomorrowio forecasts * Add assertion for correct parameters to realtime_and_all_forecasts method --- .../components/tomorrowio/__init__.py | 2 + tests/components/tomorrowio/test_weather.py | 59 ++++++++++++++++++- 2 files changed, 60 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 41fa8158624ec8..77675e3f2ec48c 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -302,6 +302,8 @@ async def _async_update_data(self) -> dict[str, Any]: [ TMRW_ATTR_TEMPERATURE_LOW, TMRW_ATTR_TEMPERATURE_HIGH, + TMRW_ATTR_DEW_POINT, + TMRW_ATTR_HUMIDITY, TMRW_ATTR_WIND_SPEED, TMRW_ATTR_WIND_DIRECTION, TMRW_ATTR_CONDITION, diff --git a/tests/components/tomorrowio/test_weather.py b/tests/components/tomorrowio/test_weather.py index a6a5e93561405f..229e62065a69da 100644 --- a/tests/components/tomorrowio/test_weather.py +++ b/tests/components/tomorrowio/test_weather.py @@ -153,9 +153,66 @@ async def test_legacy_config_entry(hass: HomeAssistant) -> None: assert len(er.async_entries_for_config_entry(registry, entry.entry_id)) == 30 -async def test_v4_weather(hass: HomeAssistant) -> None: +async def test_v4_weather(hass: HomeAssistant, tomorrowio_config_entry_update) -> None: """Test v4 weather data.""" weather_state = await _setup(hass, API_V4_ENTRY_DATA) + + tomorrowio_config_entry_update.assert_called_with( + [ + "temperature", + "humidity", + "pressureSeaLevel", + "windSpeed", + "windDirection", + "weatherCode", + "visibility", + "pollutantO3", + "windGust", + "cloudCover", + "precipitationType", + "pollutantCO", + "mepIndex", + "mepHealthConcern", + "mepPrimaryPollutant", + "cloudBase", + "cloudCeiling", + "cloudCover", + "dewPoint", + "epaIndex", + "epaHealthConcern", + "epaPrimaryPollutant", + "temperatureApparent", + "fireIndex", + "pollutantNO2", + "pollutantO3", + "particulateMatter10", + "particulateMatter25", + "grassIndex", + "treeIndex", + "weedIndex", + "precipitationType", + "pressureSurfaceLevel", + "solarGHI", + "pollutantSO2", + "uvIndex", + "uvHealthConcern", + "windGust", + ], + [ + "temperatureMin", + "temperatureMax", + "dewPoint", + "humidity", + "windSpeed", + "windDirection", + "weatherCode", + "precipitationIntensityAvg", + "precipitationProbability", + ], + nowcast_timestep=60, + location="80.0,80.0", + ) + assert weather_state.state == ATTR_CONDITION_SUNNY assert weather_state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION assert len(weather_state.attributes[ATTR_FORECAST]) == 14 From b2b57c5f87a519af951a9d1ca8777f5ae3517b92 Mon Sep 17 00:00:00 2001 From: Sam Crang Date: Fri, 8 Sep 2023 04:43:47 +0100 Subject: [PATCH 232/640] Allow exporting of `update` domain to Prometheus (#99400) --- .../components/prometheus/__init__.py | 9 ++++ tests/components/prometheus/test_init.py | 48 +++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/prometheus/__init__.py b/homeassistant/components/prometheus/__init__.py index 1818f308239353..c96ed2e4ed3591 100644 --- a/homeassistant/components/prometheus/__init__.py +++ b/homeassistant/components/prometheus/__init__.py @@ -671,6 +671,15 @@ def _handle_counter(self, state): metric.labels(**self._labels(state)).set(self.state_as_number(state)) + def _handle_update(self, state): + metric = self._metric( + "update_state", + self.prometheus_cli.Gauge, + "Update state, indicating if an update is available (0/1)", + ) + value = self.state_as_number(state) + metric.labels(**self._labels(state)).set(value) + class PrometheusView(HomeAssistantView): """Handle Prometheus requests.""" diff --git a/tests/components/prometheus/test_init.py b/tests/components/prometheus/test_init.py index 07a666946fb7ac..f24782b98d4d4e 100644 --- a/tests/components/prometheus/test_init.py +++ b/tests/components/prometheus/test_init.py @@ -24,6 +24,7 @@ prometheus, sensor, switch, + update, ) from homeassistant.components.climate import ( ATTR_CURRENT_TEMPERATURE, @@ -572,6 +573,23 @@ async def test_counter(client, counter_entities) -> None: ) +@pytest.mark.parametrize("namespace", [""]) +async def test_update(client, update_entities) -> None: + """Test prometheus metrics for update.""" + body = await generate_latest_metrics(client) + + assert ( + 'update_state{domain="update",' + 'entity="update.firmware",' + 'friendly_name="Firmware"} 1.0' in body + ) + assert ( + 'update_state{domain="update",' + 'entity="update.addon",' + 'friendly_name="Addon"} 0.0' in body + ) + + @pytest.mark.parametrize("namespace", [""]) async def test_renaming_entity_name( hass: HomeAssistant, @@ -1591,6 +1609,36 @@ async def counter_fixture( return data +@pytest.fixture(name="update_entities") +async def update_fixture( + hass: HomeAssistant, entity_registry: er.EntityRegistry +) -> dict[str, er.RegistryEntry]: + """Simulate update entities.""" + data = {} + update_1 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_1", + suggested_object_id="firmware", + original_name="Firmware", + ) + set_state_with_entry(hass, update_1, STATE_ON) + data["update_1"] = update_1 + + update_2 = entity_registry.async_get_or_create( + domain=update.DOMAIN, + platform="test", + unique_id="update_2", + suggested_object_id="addon", + original_name="Addon", + ) + set_state_with_entry(hass, update_2, STATE_OFF) + data["update_2"] = update_2 + + await hass.async_block_till_done() + return data + + def set_state_with_entry( hass: HomeAssistant, entry: er.RegistryEntry, From b2c3d959119e1ab8a03bc26782d7ece9a8485063 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 11:33:59 +0200 Subject: [PATCH 233/640] Use shorthand attributes in UPB (#99892) --- homeassistant/components/upb/light.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/upb/light.py b/homeassistant/components/upb/light.py index 4a71789423ffd7..50e6d50bb4cf65 100644 --- a/homeassistant/components/upb/light.py +++ b/homeassistant/components/upb/light.py @@ -57,7 +57,7 @@ class UpbLight(UpbAttachedEntity, LightEntity): def __init__(self, element, unique_id, upb): """Initialize an UpbLight.""" super().__init__(element, unique_id, upb) - self._brightness = self._element.status + self._attr_brightness: int = self._element.status @property def color_mode(self) -> ColorMode: @@ -78,15 +78,10 @@ def supported_features(self) -> LightEntityFeature: return LightEntityFeature.TRANSITION | LightEntityFeature.FLASH return LightEntityFeature.FLASH - @property - def brightness(self): - """Get the brightness.""" - return self._brightness - @property def is_on(self) -> bool: """Get the current brightness.""" - return self._brightness != 0 + return self._attr_brightness != 0 async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the light.""" @@ -123,4 +118,4 @@ async def async_update(self) -> None: def _element_changed(self, element, changeset): status = self._element.status - self._brightness = round(status * 2.55) if status else 0 + self._attr_brightness = round(status * 2.55) if status else 0 From 0cf32e74d620d396e75c48dcaa16056da69d1621 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 12:36:46 +0200 Subject: [PATCH 234/640] Use shorthand attributes in Tp-link (#99888) --- homeassistant/components/tplink/entity.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tplink/entity.py b/homeassistant/components/tplink/entity.py index 890793b898d03d..afb341b47edea8 100644 --- a/homeassistant/components/tplink/entity.py +++ b/homeassistant/components/tplink/entity.py @@ -41,18 +41,14 @@ def __init__( super().__init__(coordinator) self.device: SmartDevice = device self._attr_unique_id = self.device.device_id - - @property - def device_info(self) -> DeviceInfo: - """Return information about the device.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self.device.mac)}, - identifiers={(DOMAIN, str(self.device.device_id))}, + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device.mac)}, + identifiers={(DOMAIN, str(device.device_id))}, manufacturer="TP-Link", - model=self.device.model, - name=self.device.alias, - sw_version=self.device.hw_info["sw_ver"], - hw_version=self.device.hw_info["hw_ver"], + model=device.model, + name=device.alias, + sw_version=device.hw_info["sw_ver"], + hw_version=device.hw_info["hw_ver"], ) @property From 47a75cc064b6d4a95fd231568d855cd9f901f91f Mon Sep 17 00:00:00 2001 From: Ali Yousuf Date: Fri, 8 Sep 2023 07:07:33 -0400 Subject: [PATCH 235/640] Add more options to Islamic Prayer Times (#95156) --- .../islamic_prayer_times/config_flow.py | 51 ++++++++++++++++++- .../components/islamic_prayer_times/const.py | 12 +++++ .../islamic_prayer_times/coordinator.py | 34 ++++++++++++- .../islamic_prayer_times/strings.json | 24 ++++++++- .../islamic_prayer_times/test_config_flow.py | 18 ++++++- 5 files changed, 134 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/islamic_prayer_times/config_flow.py b/homeassistant/components/islamic_prayer_times/config_flow.py index 597d67c19f4db4..333b6b36c87ba4 100644 --- a/homeassistant/components/islamic_prayer_times/config_flow.py +++ b/homeassistant/components/islamic_prayer_times/config_flow.py @@ -14,7 +14,22 @@ SelectSelectorMode, ) -from .const import CALC_METHODS, CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN, NAME +from .const import ( + CALC_METHODS, + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, + LAT_ADJ_METHODS, + MIDNIGHT_MODES, + NAME, + SCHOOLS, +) class IslamicPrayerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): @@ -70,6 +85,40 @@ async def async_step_init( translation_key=CONF_CALC_METHOD, ) ), + vol.Optional( + CONF_LAT_ADJ_METHOD, + default=self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ), + ): SelectSelector( + SelectSelectorConfig( + options=LAT_ADJ_METHODS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_LAT_ADJ_METHOD, + ) + ), + vol.Optional( + CONF_MIDNIGHT_MODE, + default=self.config_entry.options.get( + CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE + ), + ): SelectSelector( + SelectSelectorConfig( + options=MIDNIGHT_MODES, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_MIDNIGHT_MODE, + ) + ), + vol.Optional( + CONF_SCHOOL, + default=self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL), + ): SelectSelector( + SelectSelectorConfig( + options=SCHOOLS, + mode=SelectSelectorMode.DROPDOWN, + translation_key=CONF_SCHOOL, + ) + ), } return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/islamic_prayer_times/const.py b/homeassistant/components/islamic_prayer_times/const.py index 67fac6c92610f4..926651738a2ecb 100644 --- a/homeassistant/components/islamic_prayer_times/const.py +++ b/homeassistant/components/islamic_prayer_times/const.py @@ -25,3 +25,15 @@ "custom", ] DEFAULT_CALC_METHOD: Final = "isna" + +CONF_LAT_ADJ_METHOD: Final = "latitude_adjustment_method" +LAT_ADJ_METHODS: Final = ["middle_of_the_night", "one_seventh", "angle_based"] +DEFAULT_LAT_ADJ_METHOD: Final = "middle_of_the_night" + +CONF_MIDNIGHT_MODE: Final = "midnight_mode" +MIDNIGHT_MODES: Final = ["standard", "jafari"] +DEFAULT_MIDNIGHT_MODE: Final = "standard" + +CONF_SCHOOL: Final = "school" +SCHOOLS: Final = ["shafi", "hanafi"] +DEFAULT_SCHOOL: Final = "shafi" diff --git a/homeassistant/components/islamic_prayer_times/coordinator.py b/homeassistant/components/islamic_prayer_times/coordinator.py index 30362c763daaaf..161ce7b26448b0 100644 --- a/homeassistant/components/islamic_prayer_times/coordinator.py +++ b/homeassistant/components/islamic_prayer_times/coordinator.py @@ -14,7 +14,17 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed import homeassistant.util.dt as dt_util -from .const import CONF_CALC_METHOD, DEFAULT_CALC_METHOD, DOMAIN +from .const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DEFAULT_CALC_METHOD, + DEFAULT_LAT_ADJ_METHOD, + DEFAULT_MIDNIGHT_MODE, + DEFAULT_SCHOOL, + DOMAIN, +) _LOGGER = logging.getLogger(__name__) @@ -38,12 +48,34 @@ def calc_method(self) -> str: """Return the calculation method.""" return self.config_entry.options.get(CONF_CALC_METHOD, DEFAULT_CALC_METHOD) + @property + def lat_adj_method(self) -> str: + """Return the latitude adjustment method.""" + return str( + self.config_entry.options.get( + CONF_LAT_ADJ_METHOD, DEFAULT_LAT_ADJ_METHOD + ).replace("_", " ") + ) + + @property + def midnight_mode(self) -> str: + """Return the midnight mode.""" + return self.config_entry.options.get(CONF_MIDNIGHT_MODE, DEFAULT_MIDNIGHT_MODE) + + @property + def school(self) -> str: + """Return the school.""" + return self.config_entry.options.get(CONF_SCHOOL, DEFAULT_SCHOOL) + def get_new_prayer_times(self) -> dict[str, Any]: """Fetch prayer times for today.""" calc = PrayerTimesCalculator( latitude=self.hass.config.latitude, longitude=self.hass.config.longitude, calculation_method=self.calc_method, + latitudeAdjustmentMethod=self.lat_adj_method, + midnightMode=self.midnight_mode, + school=self.school, date=str(dt_util.now().date()), ) return cast(dict[str, Any], calc.fetch_prayer_times()) diff --git a/homeassistant/components/islamic_prayer_times/strings.json b/homeassistant/components/islamic_prayer_times/strings.json index d02b26ec5332bc..e07a38ca1072a8 100644 --- a/homeassistant/components/islamic_prayer_times/strings.json +++ b/homeassistant/components/islamic_prayer_times/strings.json @@ -15,7 +15,10 @@ "step": { "init": { "data": { - "calculation_method": "Prayer calculation method" + "calculation_method": "Prayer calculation method", + "latitude_adjustment_method": "Latitude adjustment method", + "midnight_mode": "Midnight mode", + "school": "School" } } } @@ -40,6 +43,25 @@ "moonsighting": "Moonsighting Committee Worldwide", "custom": "Custom" } + }, + "latitude_adjustment_method": { + "options": { + "middle_of_the_night": "Middle of the night", + "one_seventh": "One seventh", + "angle_based": "Angle based" + } + }, + "midnight_mode": { + "options": { + "standard": "Standard (mid sunset to sunrise)", + "jafari": "Jafari (mid sunset to fajr)" + } + }, + "school": { + "options": { + "shafi": "Shafi", + "hanafi": "Hanafi" + } } }, "entity": { diff --git a/tests/components/islamic_prayer_times/test_config_flow.py b/tests/components/islamic_prayer_times/test_config_flow.py index a25b8ba0f0b3c7..f331c5bf49b657 100644 --- a/tests/components/islamic_prayer_times/test_config_flow.py +++ b/tests/components/islamic_prayer_times/test_config_flow.py @@ -3,7 +3,13 @@ from homeassistant import config_entries, data_entry_flow from homeassistant.components import islamic_prayer_times -from homeassistant.components.islamic_prayer_times.const import CONF_CALC_METHOD, DOMAIN +from homeassistant.components.islamic_prayer_times.const import ( + CONF_CALC_METHOD, + CONF_LAT_ADJ_METHOD, + CONF_MIDNIGHT_MODE, + CONF_SCHOOL, + DOMAIN, +) from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -44,11 +50,19 @@ 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={CONF_CALC_METHOD: "makkah"} + result["flow_id"], + user_input={ + CONF_CALC_METHOD: "makkah", + CONF_LAT_ADJ_METHOD: "one_seventh", + CONF_SCHOOL: "hanafi", + }, ) assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY assert result["data"][CONF_CALC_METHOD] == "makkah" + assert result["data"][CONF_LAT_ADJ_METHOD] == "one_seventh" + assert result["data"][CONF_MIDNIGHT_MODE] == "standard" + assert result["data"][CONF_SCHOOL] == "hanafi" async def test_integration_already_configured(hass: HomeAssistant) -> None: From 67de96adfa64bcf9dcd9f4cb0a3e30b4da6ecab5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 8 Sep 2023 13:18:26 +0200 Subject: [PATCH 236/640] Bump actions/cache from 3.3.1 to 3.3.2 (#99903) --- .github/workflows/ci.yaml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 9651b1394d8276..2e1df49549efa7 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -229,7 +229,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv key: >- @@ -244,7 +244,7 @@ jobs: pip install "$(cat requirements_test.txt | grep pre-commit)" - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} lookup-only: true @@ -274,7 +274,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -283,7 +283,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -320,7 +320,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -329,7 +329,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -369,7 +369,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -378,7 +378,7 @@ jobs: needs.info.outputs.pre-commit_cache_key }} - name: Restore pre-commit environment from cache id: cache-precommit - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: ${{ env.PRE_COMMIT_CACHE }} fail-on-cache-miss: true @@ -468,7 +468,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: venv lookup-only: true @@ -477,7 +477,7 @@ jobs: needs.info.outputs.python_cache_key }} - name: Restore pip wheel cache if: steps.cache-venv.outputs.cache-hit != 'true' - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: ${{ env.PIP_CACHE }} key: >- @@ -531,7 +531,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -563,7 +563,7 @@ jobs: check-latest: true - name: Restore base Python virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -596,7 +596,7 @@ jobs: check-latest: true - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -647,7 +647,7 @@ jobs: env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore full Python ${{ env.DEFAULT_PYTHON }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -655,7 +655,7 @@ jobs: ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.python_cache_key }} - name: Restore mypy cache - uses: actions/cache@v3.3.1 + uses: actions/cache@v3.3.2 with: path: .mypy_cache key: >- @@ -722,7 +722,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -874,7 +874,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true @@ -998,7 +998,7 @@ jobs: check-latest: true - name: Restore full Python ${{ matrix.python-version }} virtual environment id: cache-venv - uses: actions/cache/restore@v3.3.1 + uses: actions/cache/restore@v3.3.2 with: path: venv fail-on-cache-miss: true From 8742c550be71f937c886d51cbe7f0fb3e10ff711 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 06:25:25 -0500 Subject: [PATCH 237/640] Upgrade bluetooth deps to fix timeout behavior on py3.11 (#99879) --- homeassistant/components/bluetooth/manifest.json | 6 +++--- homeassistant/package_constraints.txt | 6 +++--- requirements_all.txt | 6 +++--- requirements_test_all.txt | 6 +++--- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/bluetooth/manifest.json b/homeassistant/components/bluetooth/manifest.json index 4231e03c2efa72..a3c40f739aac91 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -15,9 +15,9 @@ "quality_scale": "internal", "requirements": [ "bleak==0.21.0", - "bleak-retry-connector==3.1.2", - "bluetooth-adapters==0.16.0", - "bluetooth-auto-recovery==1.2.1", + "bleak-retry-connector==3.1.3", + "bluetooth-adapters==0.16.1", + "bluetooth-auto-recovery==1.2.2", "bluetooth-data-tools==1.11.0", "dbus-fast==1.95.2" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 629e654bb7bf0b..8fc7d6294708bf 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -8,10 +8,10 @@ atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 bleak==0.21.0 -bluetooth-adapters==0.16.0 -bluetooth-auto-recovery==1.2.1 +bluetooth-adapters==0.16.1 +bluetooth-auto-recovery==1.2.2 bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index a9ff02dfac557e..5a9d627e6e1046 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -519,7 +519,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth bleak==0.21.0 @@ -541,10 +541,10 @@ bluemaestro-ble==0.2.3 # bluepy==1.3.0 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.2 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 74c38e5d1629d7..ac9333a9a370b9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -437,7 +437,7 @@ bellows==0.36.3 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.2 +bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth bleak==0.21.0 @@ -452,10 +452,10 @@ blinkpy==0.21.0 bluemaestro-ble==0.2.3 # homeassistant.components.bluetooth -bluetooth-adapters==0.16.0 +bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.1 +bluetooth-auto-recovery==1.2.2 # homeassistant.components.bluetooth # homeassistant.components.esphome From 98ff3e233db3bac5b4d2c815cbe6bde13b57e4c6 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 06:32:21 -0500 Subject: [PATCH 238/640] Fix missing name and identifiers for ELKM1 connected devices (#99828) --- homeassistant/components/elkm1/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 352c841910643d..14046b7079b38c 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -518,6 +518,8 @@ async def async_added_to_hass(self) -> None: def device_info(self) -> DeviceInfo: """Device info connecting via the ElkM1 system.""" return DeviceInfo( + name=self._element.name, + identifiers={(DOMAIN, self._unique_id)}, via_device=(DOMAIN, f"{self._prefix}_system"), ) From e69c88a0d223c604f2b663a194e8f9a896a0181e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 8 Sep 2023 07:22:08 -0500 Subject: [PATCH 239/640] Use aliases when listing pipeline languages (#99672) --- .../assist_pipeline/websocket_api.py | 10 ++++--- homeassistant/util/language.py | 11 ++++++++ .../assist_pipeline/test_websocket.py | 26 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/assist_pipeline/websocket_api.py b/homeassistant/components/assist_pipeline/websocket_api.py index 57e2cc8b398c97..6d8fd02a21730f 100644 --- a/homeassistant/components/assist_pipeline/websocket_api.py +++ b/homeassistant/components/assist_pipeline/websocket_api.py @@ -332,7 +332,7 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages @@ -342,11 +342,15 @@ async def websocket_list_languages( dialect = language_util.Dialect.parse(language_tag) languages.add(dialect.language) if pipeline_languages is not None: - pipeline_languages &= languages + pipeline_languages = language_util.intersect(pipeline_languages, languages) else: pipeline_languages = languages connection.send_result( msg["id"], - {"languages": pipeline_languages}, + { + "languages": sorted(pipeline_languages) + if pipeline_languages + else pipeline_languages + }, ) diff --git a/homeassistant/util/language.py b/homeassistant/util/language.py index 4ec8c74ffa9fd7..73db81c91cebfd 100644 --- a/homeassistant/util/language.py +++ b/homeassistant/util/language.py @@ -199,3 +199,14 @@ def matches( # Score < 0 is not a match return [tag for _dialect, score, tag in scored if score[0] >= 0] + + +def intersect(languages_1: set[str], languages_2: set[str]) -> set[str]: + """Intersect two sets of languages using is_match for aliases.""" + languages = set() + for lang_1 in languages_1: + for lang_2 in languages_2: + if is_language_match(lang_1, lang_2): + languages.add(lang_1) + + return languages diff --git a/tests/components/assist_pipeline/test_websocket.py b/tests/components/assist_pipeline/test_websocket.py index ca631be4549bb5..a7ba9063b3fb61 100644 --- a/tests/components/assist_pipeline/test_websocket.py +++ b/tests/components/assist_pipeline/test_websocket.py @@ -1633,3 +1633,29 @@ async def test_list_pipeline_languages( msg = await client.receive_json() assert msg["success"] assert msg["result"] == {"languages": ["en"]} + + +async def test_list_pipeline_languages_with_aliases( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + init_components, +) -> None: + """Test listing pipeline languages using aliases.""" + client = await hass_ws_client(hass) + + with patch( + "homeassistant.components.conversation.async_get_conversation_languages", + return_value={"he", "nb"}, + ), patch( + "homeassistant.components.stt.async_get_speech_to_text_languages", + return_value={"he", "no"}, + ), patch( + "homeassistant.components.tts.async_get_text_to_speech_languages", + return_value={"iw", "nb"}, + ): + await client.send_json_auto_id({"type": "assist_pipeline/language/list"}) + + # result + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] == {"languages": ["he", "nb"]} From 5ddaf52b27e7aebc5cdc22e93cc5109935ce7999 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 14:29:59 +0200 Subject: [PATCH 240/640] Use shorthand attributes in Wilight (#99920) --- homeassistant/components/wilight/switch.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/wilight/switch.py b/homeassistant/components/wilight/switch.py index 101162302ae559..334d750b1e1c28 100644 --- a/homeassistant/components/wilight/switch.py +++ b/homeassistant/components/wilight/switch.py @@ -149,6 +149,7 @@ class WiLightValveSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve switch.""" _attr_translation_key = "watering" + _attr_icon = ICON_WATERING @property def is_on(self) -> bool: @@ -237,11 +238,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_WATERING - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) @@ -270,6 +266,7 @@ class WiLightValvePauseSwitch(WiLightDevice, SwitchEntity): """Representation of a WiLights Valve Pause switch.""" _attr_translation_key = "pause" + _attr_icon = ICON_PAUSE @property def is_on(self) -> bool: @@ -297,11 +294,6 @@ def extra_state_attributes(self) -> dict[str, Any]: return attr - @property - def icon(self) -> str: - """Return the icon to use in the frontend.""" - return ICON_PAUSE - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" await self._client.turn_on(self._index) From 5f6f2c2cab4cdd1b0aabadb1f9f3d781b3b43f0a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 14:38:09 +0200 Subject: [PATCH 241/640] Use shorthand attributes in Wolflink (#99921) --- homeassistant/components/wolflink/sensor.py | 46 ++++----------------- 1 file changed, 8 insertions(+), 38 deletions(-) diff --git a/homeassistant/components/wolflink/sensor.py b/homeassistant/components/wolflink/sensor.py index 60883a0acf5dab..b4d60011658667 100644 --- a/homeassistant/components/wolflink/sensor.py +++ b/homeassistant/components/wolflink/sensor.py @@ -57,14 +57,10 @@ def __init__(self, coordinator, wolf_object: Parameter, device_id) -> None: """Initialize.""" super().__init__(coordinator) self.wolf_object = wolf_object - self.device_id = device_id + self._attr_name = wolf_object.name + self._attr_unique_id = f"{device_id}:{wolf_object.parameter_id}" self._state = None - @property - def name(self): - """Return the name.""" - return f"{self.wolf_object.name}" - @property def native_value(self): """Return the state. Wolf Client is returning only changed values so we need to store old value here.""" @@ -83,52 +79,26 @@ def extra_state_attributes(self): "parent": self.wolf_object.parent, } - @property - def unique_id(self): - """Return a unique_id for this entity.""" - return f"{self.device_id}:{self.wolf_object.parameter_id}" - class WolfLinkHours(WolfLinkSensor): """Class for hour based entities.""" - @property - def icon(self): - """Icon to display in the front Aend.""" - return "mdi:clock" - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTime.HOURS + _attr_icon = "mdi:clock" + _attr_native_unit_of_measurement = UnitOfTime.HOURS class WolfLinkTemperature(WolfLinkSensor): """Class for temperature based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.TEMPERATURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfTemperature.CELSIUS + _attr_device_class = SensorDeviceClass.TEMPERATURE + _attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS class WolfLinkPressure(WolfLinkSensor): """Class for pressure based entities.""" - @property - def device_class(self): - """Return the device_class.""" - return SensorDeviceClass.PRESSURE - - @property - def native_unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return UnitOfPressure.BAR + _attr_device_class = SensorDeviceClass.PRESSURE + _attr_native_unit_of_measurement = UnitOfPressure.BAR class WolfLinkPercentage(WolfLinkSensor): From c6f8766b1e68eda612181677752e7060787118d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 17:27:18 +0200 Subject: [PATCH 242/640] Use shorthand attributes in Zerproc (#99926) --- homeassistant/components/zerproc/light.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/zerproc/light.py b/homeassistant/components/zerproc/light.py index 884f87d36f6fc4..c6be3c70e659ec 100644 --- a/homeassistant/components/zerproc/light.py +++ b/homeassistant/components/zerproc/light.py @@ -88,6 +88,12 @@ class ZerprocLight(LightEntity): def __init__(self, light) -> None: """Initialize a Zerproc light.""" self._light = light + self._attr_unique_id = light.address + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.address)}, + manufacturer="Zerproc", + name=light.name, + ) async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" @@ -108,20 +114,6 @@ async def async_will_remove_from_hass(self) -> None: "Exception disconnecting from %s", self._light.address, exc_info=True ) - @property - def unique_id(self): - """Return the ID of this light.""" - return self._light.address - - @property - def device_info(self) -> DeviceInfo: - """Device info for this light.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Zerproc", - name=self._light.name, - ) - async def async_turn_on(self, **kwargs: Any) -> None: """Instruct the light to turn on.""" if ATTR_BRIGHTNESS in kwargs or ATTR_HS_COLOR in kwargs: From 38247ae86860fbcbfb8405e1ba80e6afcb8a03bb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 17:31:57 +0200 Subject: [PATCH 243/640] Use shorthand attributes in Volumio (#99918) --- .../components/volumio/media_player.py | 47 +++++-------------- 1 file changed, 13 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/volumio/media_player.py b/homeassistant/components/volumio/media_player.py index d207e36e3c964f..a11ea62e355ce4 100644 --- a/homeassistant/components/volumio/media_player.py +++ b/homeassistant/components/volumio/media_player.py @@ -69,39 +69,28 @@ class Volumio(MediaPlayerEntity): | MediaPlayerEntityFeature.CLEAR_PLAYLIST | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _attr_source_list = [] def __init__(self, volumio, uid, name, info): """Initialize the media player.""" self._volumio = volumio - self._uid = uid - self._name = name - self._info = info + unique_id = uid self._state = {} - self._playlists = [] - self._currentplaylist = None self.thumbnail_cache = {} + self._attr_unique_id = unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, unique_id)}, + manufacturer="Volumio", + model=info["hardware"], + name=name, + sw_version=info["systemversion"], + ) async def async_update(self) -> None: """Update state.""" self._state = await self._volumio.get_state() await self._async_update_playlists() - @property - def unique_id(self): - """Return the unique id for the entity.""" - return self._uid - - @property - def device_info(self) -> DeviceInfo: - """Return device info for this device.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer="Volumio", - model=self._info["hardware"], - name=self._name, - sw_version=self._info["systemversion"], - ) - @property def state(self) -> MediaPlayerState: """Return the state of the device.""" @@ -169,16 +158,6 @@ def repeat(self) -> RepeatMode: return RepeatMode.ALL return RepeatMode.OFF - @property - def source_list(self): - """Return the list of available input sources.""" - return self._playlists - - @property - def source(self): - """Name of the current input source.""" - return self._currentplaylist - async def async_media_next_track(self) -> None: """Send media_next command to media player.""" await self._volumio.next() @@ -235,17 +214,17 @@ async def async_set_repeat(self, repeat: RepeatMode) -> None: async def async_select_source(self, source: str) -> None: """Choose an available playlist and play it.""" await self._volumio.play_playlist(source) - self._currentplaylist = source + self._attr_source = source async def async_clear_playlist(self) -> None: """Clear players playlist.""" await self._volumio.clear_playlist() - self._currentplaylist = None + self._attr_source = None @Throttle(PLAYLIST_UPDATE_INTERVAL) async def _async_update_playlists(self, **kwargs): """Update available Volumio playlists.""" - self._playlists = await self._volumio.get_playlists() + self._attr_source_list = await self._volumio.get_playlists() async def async_play_media( self, media_type: MediaType | str, media_id: str, **kwargs: Any From 16f7bc7bf89c0b98ec93df9300732078011454dc Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Fri, 8 Sep 2023 18:59:08 +0200 Subject: [PATCH 244/640] Update frontend to 20230908.0 (#99939) --- 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 50c557eae89636..58de25fc03ddc7 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==20230906.1"] + "requirements": ["home-assistant-frontend==20230908.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8fc7d6294708bf..02d78b1dfeffcb 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 5a9d627e6e1046..6f36f1bca2b16e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -995,7 +995,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ac9333a9a370b9..ab98ebbd035bad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -778,7 +778,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230906.1 +home-assistant-frontend==20230908.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From 3d403c9b6020afcce6d625a945fd5f36486c820d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:04:53 -0500 Subject: [PATCH 245/640] Refactor entity service calls to reduce complexity (#99783) * Refactor entity service calls to reduce complexity gets rid of the noqa C901 * Refactor entity service calls to reduce complexity gets rid of the noqa C901 * short --- homeassistant/helpers/service.py | 114 ++++++++++++++++--------------- 1 file changed, 60 insertions(+), 54 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 3eb537f96497d0..a0fe24cb6564a5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -732,8 +732,59 @@ def async_set_service_schema( descriptions_cache[(domain, service)] = description +def _get_permissible_entity_candidates( + call: ServiceCall, + platforms: Iterable[EntityPlatform], + entity_perms: None | (Callable[[str, str], bool]), + target_all_entities: bool, + all_referenced: set[str] | None, +) -> list[Entity]: + """Get entity candidates that the user is allowed to access.""" + if entity_perms is not None: + # Check the permissions since entity_perms is set + if target_all_entities: + # If we target all entities, we will select all entities the user + # is allowed to control. + return [ + entity + for platform in platforms + for entity in platform.entities.values() + if entity_perms(entity.entity_id, POLICY_CONTROL) + ] + + assert all_referenced is not None + # If they reference specific entities, we will check if they are all + # allowed to be controlled. + for entity_id in all_referenced: + if not entity_perms(entity_id, POLICY_CONTROL): + raise Unauthorized( + context=call.context, + entity_id=entity_id, + permission=POLICY_CONTROL, + ) + + elif target_all_entities: + return [ + entity for platform in platforms for entity in platform.entities.values() + ] + + # We have already validated they have permissions to control all_referenced + # entities so we do not need to check again. + assert all_referenced is not None + if single_entity := len(all_referenced) == 1 and list(all_referenced)[0]: + for platform in platforms: + if (entity := platform.entities.get(single_entity)) is not None: + return [entity] + + return [ + platform.entities[entity_id] + for platform in platforms + for entity_id in all_referenced.intersection(platform.entities) + ] + + @bind_hass -async def entity_service_call( # noqa: C901 +async def entity_service_call( hass: HomeAssistant, platforms: Iterable[EntityPlatform], func: str | Callable[..., Coroutine[Any, Any, ServiceResponse]], @@ -771,69 +822,24 @@ async def entity_service_call( # noqa: C901 else: data = call - # Check the permissions - # A list with entities to call the service on. - entity_candidates: list[Entity] = [] - - if entity_perms is None: - for platform in platforms: - platform_entities = platform.entities - if target_all_entities: - entity_candidates.extend(platform_entities.values()) - else: - assert all_referenced is not None - entity_candidates.extend( - [ - platform_entities[entity_id] - for entity_id in all_referenced.intersection(platform_entities) - ] - ) - - elif target_all_entities: - # If we target all entities, we will select all entities the user - # is allowed to control. - for platform in platforms: - entity_candidates.extend( - [ - entity - for entity in platform.entities.values() - if entity_perms(entity.entity_id, POLICY_CONTROL) - ] - ) - - else: - assert all_referenced is not None - - for platform in platforms: - platform_entities = platform.entities - platform_entity_candidates = [] - entity_id_matches = all_referenced.intersection(platform_entities) - for entity_id in entity_id_matches: - if not entity_perms(entity_id, POLICY_CONTROL): - raise Unauthorized( - context=call.context, - entity_id=entity_id, - permission=POLICY_CONTROL, - ) - - platform_entity_candidates.append(platform_entities[entity_id]) - - entity_candidates.extend(platform_entity_candidates) + entity_candidates = _get_permissible_entity_candidates( + call, + platforms, + entity_perms, + target_all_entities, + all_referenced, + ) if not target_all_entities: assert referenced is not None - # Only report on explicit referenced entities - missing = set(referenced.referenced) - + missing = referenced.referenced.copy() for entity in entity_candidates: missing.discard(entity.entity_id) - referenced.log_missing(missing) entities: list[Entity] = [] - for entity in entity_candidates: if not entity.available: continue From d624bbbc0c60d75fbd809dedb77b0156c4a1b8c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:07:09 -0500 Subject: [PATCH 246/640] Migrate elkm1 to use a dataclass for integration data (#99830) * Migrate elkm1 to use a dataclass for integration data * fix unsaved * slotted * missing coveragerc * Revert "missing coveragerc" This reverts commit 3397b40309033276d20fef59098b0a1b5b681a30. --- homeassistant/components/elkm1/__init__.py | 58 ++++++++++--------- .../components/elkm1/alarm_control_panel.py | 8 ++- .../components/elkm1/binary_sensor.py | 12 ++-- homeassistant/components/elkm1/climate.py | 5 +- homeassistant/components/elkm1/light.py | 7 ++- homeassistant/components/elkm1/models.py | 19 ++++++ homeassistant/components/elkm1/scene.py | 5 +- homeassistant/components/elkm1/sensor.py | 5 +- homeassistant/components/elkm1/switch.py | 5 +- 9 files changed, 77 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/elkm1/models.py diff --git a/homeassistant/components/elkm1/__init__.py b/homeassistant/components/elkm1/__init__.py index 14046b7079b38c..b78157588e82e2 100644 --- a/homeassistant/components/elkm1/__init__.py +++ b/homeassistant/components/elkm1/__init__.py @@ -2,11 +2,12 @@ from __future__ import annotations import asyncio +from collections.abc import Iterable from enum import Enum import logging import re from types import MappingProxyType -from typing import Any, cast +from typing import Any from elkm1_lib.elements import Element from elkm1_lib.elk import Elk @@ -65,6 +66,7 @@ async_trigger_discovery, async_update_entry_from_discovery, ) +from .models import ELKM1Data SYNC_TIMEOUT = 120 @@ -303,14 +305,16 @@ def _keypad_changed(keypad: Element, changeset: dict[str, Any]) -> None: else: temperature_unit = UnitOfTemperature.FAHRENHEIT config["temperature_unit"] = temperature_unit - hass.data[DOMAIN][entry.entry_id] = { - "elk": elk, - "prefix": conf[CONF_PREFIX], - "mac": entry.unique_id, - "auto_configure": conf[CONF_AUTO_CONFIGURE], - "config": config, - "keypads": {}, - } + prefix: str = conf[CONF_PREFIX] + auto_configure: bool = conf[CONF_AUTO_CONFIGURE] + hass.data[DOMAIN][entry.entry_id] = ELKM1Data( + elk=elk, + prefix=prefix, + mac=entry.unique_id, + auto_configure=auto_configure, + config=config, + keypads={}, + ) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -326,21 +330,23 @@ def _included(ranges: list[tuple[int, int]], set_to: bool, values: list[bool]) - def _find_elk_by_prefix(hass: HomeAssistant, prefix: str) -> Elk | None: """Search all config entries for a given prefix.""" - for entry_id in hass.data[DOMAIN]: - if hass.data[DOMAIN][entry_id]["prefix"] == prefix: - return cast(Elk, hass.data[DOMAIN][entry_id]["elk"]) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] + for elk_data in all_elk.values(): + if elk_data.prefix == prefix: + return elk_data.elk return None 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) + all_elk: dict[str, ELKM1Data] = hass.data[DOMAIN] # disconnect cleanly - hass.data[DOMAIN][entry.entry_id]["elk"].disconnect() + all_elk[entry.entry_id].elk.disconnect() if unload_ok: - hass.data[DOMAIN].pop(entry.entry_id) + all_elk.pop(entry.entry_id) return unload_ok @@ -421,19 +427,19 @@ def _set_time_service(service: ServiceCall) -> None: def create_elk_entities( - elk_data: dict[str, Any], - elk_elements: list[Element], + elk_data: ELKM1Data, + elk_elements: Iterable[Element], element_type: str, class_: Any, entities: list[ElkEntity], ) -> list[ElkEntity] | None: """Create the ElkM1 devices of a particular class.""" - auto_configure = elk_data["auto_configure"] + auto_configure = elk_data.auto_configure - if not auto_configure and not elk_data["config"][element_type]["enabled"]: + if not auto_configure and not elk_data.config[element_type]["enabled"]: return None - elk = elk_data["elk"] + elk = elk_data.elk _LOGGER.debug("Creating elk entities for %s", elk) for element in elk_elements: @@ -441,7 +447,7 @@ def create_elk_entities( if not element.configured: continue # Only check the included list if auto configure is not - elif not elk_data["config"][element_type]["included"][element.index]: + elif not elk_data.config[element_type]["included"][element.index]: continue entities.append(class_(element, elk, elk_data)) @@ -454,13 +460,13 @@ class ElkEntity(Entity): _attr_has_entity_name = True _attr_should_poll = False - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the base of all Elk devices.""" self._elk = elk self._element = element - self._mac = elk_data["mac"] - self._prefix = elk_data["prefix"] - self._temperature_unit: str = elk_data["config"]["temperature_unit"] + self._mac = elk_data.mac + self._prefix = elk_data.prefix + self._temperature_unit: str = elk_data.config["temperature_unit"] # unique_id starts with elkm1_ iff there is no prefix # it starts with elkm1m_{prefix} iff there is a prefix # this is to avoid a conflict between @@ -496,9 +502,7 @@ def available(self) -> bool: def initial_attrs(self) -> dict[str, Any]: """Return the underlying element's attributes as a dict.""" - attrs = {} - attrs["index"] = self._element.index + 1 - return attrs + return {"index": self._element.index + 1} def _element_changed(self, element: Element, changeset: dict[str, Any]) -> None: pass diff --git a/homeassistant/components/elkm1/alarm_control_panel.py b/homeassistant/components/elkm1/alarm_control_panel.py index 3f5163a849dce4..bfac466caeb921 100644 --- a/homeassistant/components/elkm1/alarm_control_panel.py +++ b/homeassistant/components/elkm1/alarm_control_panel.py @@ -40,6 +40,7 @@ DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA, ) +from .models import ELKM1Data DISPLAY_MESSAGE_SERVICE_SCHEMA = { vol.Optional("clear", default=2): vol.All(vol.Coerce(int), vol.In([0, 1, 2])), @@ -65,8 +66,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the ElkM1 alarm platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] - elk = elk_data["elk"] + + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] create_elk_entities(elk_data, elk.areas, "area", ElkArea, entities) async_add_entities(entities) @@ -115,7 +117,7 @@ class ElkArea(ElkAttachedEntity, AlarmControlPanelEntity, RestoreEntity): ) _element: Area - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize Area as Alarm Control Panel.""" super().__init__(element, elk, elk_data) self._elk = elk diff --git a/homeassistant/components/elkm1/binary_sensor.py b/homeassistant/components/elkm1/binary_sensor.py index 38a72796482934..95f9162468e3d7 100644 --- a/homeassistant/components/elkm1/binary_sensor.py +++ b/homeassistant/components/elkm1/binary_sensor.py @@ -14,6 +14,7 @@ from . import ElkAttachedEntity, ElkEntity from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,21 +23,20 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - - elk_data = hass.data[DOMAIN][config_entry.entry_id] - auto_configure = elk_data["auto_configure"] - elk = elk_data["elk"] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk + auto_configure = elk_data.auto_configure entities: list[ElkEntity] = [] for element in elk.zones: # Don't create binary sensors for zones that are analog - if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: + if element.definition in {ZoneType.TEMPERATURE, ZoneType.ANALOG_ZONE}: # type: ignore[attr-defined] continue if auto_configure: if not element.configured: continue - elif not elk_data["config"]["zone"]["included"][element.index]: + elif not elk_data.config["zone"]["included"][element.index]: continue entities.append(ElkBinarySensor(element, elk, elk_data)) diff --git a/homeassistant/components/elkm1/climate.py b/homeassistant/components/elkm1/climate.py index 1ece7a7758a0b8..c1e6dc7b034df9 100644 --- a/homeassistant/components/elkm1/climate.py +++ b/homeassistant/components/elkm1/climate.py @@ -23,6 +23,7 @@ from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data SUPPORT_HVAC = [ HVACMode.OFF, @@ -61,9 +62,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 thermostat platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities( elk_data, elk.thermostats, "thermostat", ElkThermostat, entities ) diff --git a/homeassistant/components/elkm1/light.py b/homeassistant/components/elkm1/light.py index 3db457761aacd6..844e4f3dd15569 100644 --- a/homeassistant/components/elkm1/light.py +++ b/homeassistant/components/elkm1/light.py @@ -14,6 +14,7 @@ from . import ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -22,9 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Elk light platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.lights, "plc", ElkLight, entities) async_add_entities(entities) @@ -36,7 +37,7 @@ class ElkLight(ElkEntity, LightEntity): _attr_supported_color_modes = {ColorMode.BRIGHTNESS} _element: Light - def __init__(self, element: Element, elk: Elk, elk_data: dict[str, Any]) -> None: + def __init__(self, element: Element, elk: Elk, elk_data: ELKM1Data) -> None: """Initialize the Elk light.""" super().__init__(element, elk, elk_data) self._brightness = self._element.status diff --git a/homeassistant/components/elkm1/models.py b/homeassistant/components/elkm1/models.py new file mode 100644 index 00000000000000..9f784951c11c87 --- /dev/null +++ b/homeassistant/components/elkm1/models.py @@ -0,0 +1,19 @@ +"""The elkm1 integration models.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from elkm1_lib import Elk + + +@dataclass(slots=True) +class ELKM1Data: + """Data for the elkm1 integration.""" + + elk: Elk + prefix: str + mac: str | None + auto_configure: bool + config: dict[str, Any] + keypads: dict[str, Any] diff --git a/homeassistant/components/elkm1/scene.py b/homeassistant/components/elkm1/scene.py index 1869e5ba0f3446..9cb0c62ff77434 100644 --- a/homeassistant/components/elkm1/scene.py +++ b/homeassistant/components/elkm1/scene.py @@ -12,6 +12,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 scene platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.tasks, "task", ElkTask, entities) async_add_entities(entities) diff --git a/homeassistant/components/elkm1/sensor.py b/homeassistant/components/elkm1/sensor.py index 0de97a1710e516..9bd78f6167343d 100644 --- a/homeassistant/components/elkm1/sensor.py +++ b/homeassistant/components/elkm1/sensor.py @@ -23,6 +23,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import ATTR_VALUE, DOMAIN, ELK_USER_CODE_SERVICE_SCHEMA +from .models import ELKM1Data SERVICE_SENSOR_COUNTER_REFRESH = "sensor_counter_refresh" SERVICE_SENSOR_COUNTER_SET = "sensor_counter_set" @@ -41,9 +42,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 sensor platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.counters, "counter", ElkCounter, entities) create_elk_entities(elk_data, elk.keypads, "keypad", ElkKeypad, entities) create_elk_entities(elk_data, [elk.panel], "panel", ElkPanel, entities) diff --git a/homeassistant/components/elkm1/switch.py b/homeassistant/components/elkm1/switch.py index a17557b15077e7..b4080adc6987fe 100644 --- a/homeassistant/components/elkm1/switch.py +++ b/homeassistant/components/elkm1/switch.py @@ -12,6 +12,7 @@ from . import ElkAttachedEntity, ElkEntity, create_elk_entities from .const import DOMAIN +from .models import ELKM1Data async def async_setup_entry( @@ -20,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Create the Elk-M1 switch platform.""" - elk_data = hass.data[DOMAIN][config_entry.entry_id] + elk_data: ELKM1Data = hass.data[DOMAIN][config_entry.entry_id] + elk = elk_data.elk entities: list[ElkEntity] = [] - elk = elk_data["elk"] create_elk_entities(elk_data, elk.outputs, "output", ElkOutput, entities) async_add_entities(entities) From 9a45e2cf91ef6344992bcedd1cf8cb1db7ca47fc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 8 Sep 2023 19:08:32 +0200 Subject: [PATCH 247/640] Bump pyenphase to v1.11.0 (#99941) --- 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 d3a36b16b60b63..c6d127a3f6eea8 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.9.3"], + "requirements": ["pyenphase==1.11.0"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 6f36f1bca2b16e..8ee56b9bdc75dd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1672,7 +1672,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.3 +pyenphase==1.11.0 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab98ebbd035bad..daeddff9e49b6b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1239,7 +1239,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.9.3 +pyenphase==1.11.0 # homeassistant.components.everlights pyeverlights==0.1.0 From bd1d8675a9f8a2f7555724d6389e90a83c4c4532 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:09:29 -0500 Subject: [PATCH 248/640] Avoid many hass.is_stopping calls in the discovery helper (#99929) async_has_matching_flow is more likely to be True than hass.is_stopping This does not make much difference but it was adding noise to a profile that I am digging into to look for another issue --- homeassistant/helpers/discovery_flow.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/discovery_flow.py b/homeassistant/helpers/discovery_flow.py index 586824b4495fc5..306e8b51d63abb 100644 --- a/homeassistant/helpers/discovery_flow.py +++ b/homeassistant/helpers/discovery_flow.py @@ -44,8 +44,9 @@ def _async_init_flow( # as ones in progress as it may cause additional device probing # which can overload devices since zeroconf/ssdp updates can happen # multiple times in the same minute - if hass.is_stopping or hass.config_entries.flow.async_has_matching_flow( - domain, context, data + if ( + hass.config_entries.flow.async_has_matching_flow(domain, context, data) + or hass.is_stopping ): return None From 677431ed718eaac0ffe279c63fd4e505058e2b66 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Fri, 8 Sep 2023 19:10:17 +0200 Subject: [PATCH 249/640] Fix key error MQTT binary_sensor when no name is set (#99943) Log entitty ID when instead of name --- homeassistant/components/mqtt/binary_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index b5c7bc987896f4..a1341350a7a4e7 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -215,7 +215,7 @@ def state_message_received(msg: ReceiveMessage) -> None: "Empty template output for entity: %s with state topic: %s." " Payload: '%s', with value template '%s'" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, self._config.get(CONF_VALUE_TEMPLATE), @@ -240,7 +240,7 @@ def state_message_received(msg: ReceiveMessage) -> None: "No matching payload found for entity: %s with state topic: %s." " Payload: '%s'%s" ), - self._config[CONF_NAME], + self.entity_id, self._config[CONF_STATE_TOPIC], msg.payload, template_info, From be4ea320493eb190f7ae2a6d00dba6c2bc9f4f41 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 8 Sep 2023 19:20:06 +0200 Subject: [PATCH 250/640] Bump pymodbus v.3.5.1 (#99940) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index a4187de77ebd15..bef85f1d20d5ee 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.0"] + "requirements": ["pymodbus==3.5.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8ee56b9bdc75dd..d6992e4ce17c20 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,7 +1852,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.1 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index daeddff9e49b6b..5a4488fd06aa59 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1374,7 +1374,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.0 +pymodbus==3.5.1 # homeassistant.components.monoprice pymonoprice==0.4 From d1ac4c9c467657c4f1b548fd5088d6d01dbc48c5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 12:59:25 -0500 Subject: [PATCH 251/640] Switch a few ssdp calls to use get_lower (#99931) get_lower avoids lower casing already lower-cased strings --- homeassistant/components/ssdp/__init__.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 3be5475a71abfa..986eabf4e82808 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -606,7 +606,7 @@ def discovery_info_from_headers_and_description( ) -> SsdpServiceInfo: """Convert headers and description to discovery_info.""" ssdp_usn = combined_headers["usn"] - ssdp_st = combined_headers.get("st") + ssdp_st = combined_headers.get_lower("st") if isinstance(info_desc, CaseInsensitiveDict): upnp_info = {**info_desc.as_dict()} else: @@ -626,11 +626,11 @@ def discovery_info_from_headers_and_description( return SsdpServiceInfo( ssdp_usn=ssdp_usn, ssdp_st=ssdp_st, - ssdp_ext=combined_headers.get("ext"), - ssdp_server=combined_headers.get("server"), - ssdp_location=combined_headers.get("location"), - ssdp_udn=combined_headers.get("_udn"), - ssdp_nt=combined_headers.get("nt"), + ssdp_ext=combined_headers.get_lower("ext"), + ssdp_server=combined_headers.get_lower("server"), + ssdp_location=combined_headers.get_lower("location"), + ssdp_udn=combined_headers.get_lower("_udn"), + ssdp_nt=combined_headers.get_lower("nt"), ssdp_headers=combined_headers, upnp=upnp_info, ) From f0ee20c15c4c0f6f4e0dd9604c2dc226e3b72463 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 13:59:35 -0500 Subject: [PATCH 252/640] Bump orjson to 3.9.7 (#99938) --- 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 02d78b1dfeffcb..59005c7bf49f25 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.46.0 -orjson==3.9.2 +orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 Pillow==10.0.0 diff --git a/pyproject.toml b/pyproject.toml index e535e7bbc7bc7f..e62bdbf3e30e4b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -44,7 +44,7 @@ dependencies = [ "cryptography==41.0.3", # pyOpenSSL 23.2.0 is required to work with cryptography 41+ "pyOpenSSL==23.2.0", - "orjson==3.9.2", + "orjson==3.9.7", "packaging>=23.1", "pip>=21.3.1", "python-slugify==4.0.1", diff --git a/requirements.txt b/requirements.txt index e7a3b0fc4c5df3..28e853f4fe1cf5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -18,7 +18,7 @@ lru-dict==1.2.0 PyJWT==2.8.0 cryptography==41.0.3 pyOpenSSL==23.2.0 -orjson==3.9.2 +orjson==3.9.7 packaging>=23.1 pip>=21.3.1 python-slugify==4.0.1 From b317e04cf154405f39a586ca8a24e703425d051b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Sep 2023 21:01:34 +0200 Subject: [PATCH 253/640] Bump hatasmota to 0.7.1 (#99818) --- homeassistant/components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_sensor.py | 10 ++++++++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 220bc4e31fb7ca..9843f64fc25b4d 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.0"] + "requirements": ["HATasmota==0.7.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index d6992e4ce17c20..1380ebbfa8a2d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.1 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a4488fd06aa59..c785034cc70b70 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.0 +HATasmota==0.7.1 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index 4e79b8ad0d568e..c14c7ffe53c4ae 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -626,6 +626,16 @@ async def test_battery_sensor_state_via_mqtt( "unit_of_measurement": "%", } + # Test polled state update + async_fire_mqtt_message( + hass, + "tasmota_49A3BC/stat/STATUS11", + '{"StatusSTS":{"BatteryPercentage":50}}', + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.tasmota_battery_level") + assert state.state == "50" + @pytest.mark.parametrize("status_sensor_disabled", [False]) async def test_single_shot_status_sensor_state_via_mqtt( From 1654ef77595fc85c9d53c63609f799044472478b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 8 Sep 2023 21:02:06 +0200 Subject: [PATCH 254/640] Make WS command render_template not give up if initial render raises (#99808) --- .../components/websocket_api/commands.py | 6 +- homeassistant/helpers/event.py | 11 +- .../components/websocket_api/test_commands.py | 311 ++++++++++++++++-- tests/helpers/test_event.py | 21 -- 4 files changed, 278 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index a05f2aa8e3fc0f..ea21b7b5ebadd9 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -542,9 +542,8 @@ def _thread_safe_error_listener(level: int, template_error: str) -> None: timed_out = await template_obj.async_render_will_timeout( timeout, variables, strict=msg["strict"], log_fn=log_fn ) - except TemplateError as ex: - connection.send_error(msg["id"], const.ERR_TEMPLATE_ERROR, str(ex)) - return + except TemplateError: + timed_out = False if timed_out: connection.send_error( @@ -583,7 +582,6 @@ def _template_listener( hass, [TrackTemplate(template_obj, variables)], _template_listener, - raise_on_template_error=True, strict=msg["strict"], log_fn=log_fn, ) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index 76e73401bebe95..2da8a48be987ee 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -917,7 +917,6 @@ def __repr__(self) -> str: def async_setup( self, - raise_on_template_error: bool, strict: bool = False, log_fn: Callable[[int, str], None] | None = None, ) -> None: @@ -955,8 +954,6 @@ def async_setup( ) if info.exception: - if raise_on_template_error: - raise info.exception if not log_fn: _LOGGER.error( "Error while processing template: %s", @@ -1239,7 +1236,6 @@ def async_track_template_result( hass: HomeAssistant, track_templates: Sequence[TrackTemplate], action: TrackTemplateResultListener, - raise_on_template_error: bool = False, strict: bool = False, log_fn: Callable[[int, str], None] | None = None, has_super_template: bool = False, @@ -1266,11 +1262,6 @@ def async_track_template_result( An iterable of TrackTemplate. action Callable to call with results. - raise_on_template_error - When set to True, if there is an exception - processing the template during setup, the system - will raise the exception instead of setting up - tracking. strict When set to True, raise on undefined variables. log_fn @@ -1286,7 +1277,7 @@ def async_track_template_result( """ tracker = TrackTemplateResultInfo(hass, track_templates, action, has_super_template) - tracker.async_setup(raise_on_template_error, strict=strict, log_fn=log_fn) + tracker.async_setup(strict=strict, log_fn=log_fn) return tracker diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 70f08477a729d8..b1b2027c65d5e8 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -1234,27 +1234,27 @@ async def test_render_template_manual_entity_ids_no_longer_needed( ERR_MSG = {"type": "result", "success": False} -VARIABLE_ERROR_UNDEFINED_FUNC = { +EVENT_UNDEFINED_FUNC_1 = { "error": "'my_unknown_func' is undefined", "level": "ERROR", } -TEMPLATE_ERROR_UNDEFINED_FUNC = { - "code": "template_error", - "message": "UndefinedError: 'my_unknown_func' is undefined", +EVENT_UNDEFINED_FUNC_2 = { + "error": "UndefinedError: 'my_unknown_func' is undefined", + "level": "ERROR", } -VARIABLE_WARNING_UNDEFINED_VAR = { +EVENT_UNDEFINED_VAR_WARN = { "error": "'my_unknown_var' is undefined", "level": "WARNING", } -TEMPLATE_ERROR_UNDEFINED_VAR = { - "code": "template_error", - "message": "UndefinedError: 'my_unknown_var' is undefined", +EVENT_UNDEFINED_VAR_ERR = { + "error": "UndefinedError: 'my_unknown_var' is undefined", + "level": "ERROR", } -TEMPLATE_ERROR_UNDEFINED_FILTER = { - "code": "template_error", - "message": "TemplateAssertionError: No filter named 'unknown_filter'.", +EVENT_UNDEFINED_FILTER = { + "error": "TemplateAssertionError: No filter named 'unknown_filter'.", + "level": "ERROR", } @@ -1264,16 +1264,19 @@ async def test_render_template_manual_entity_ids_no_longer_needed( ( "{{ my_unknown_func() + 1 }}", [ - {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, - ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, ], ), ( "{{ my_unknown_var }}", [ - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, {"type": "result", "success": True, "result": None}, - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, { "type": "event", "event": {"result": "", "listeners": EMPTY_LISTENERS}, @@ -1282,11 +1285,19 @@ async def test_render_template_manual_entity_ids_no_longer_needed( ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1325,16 +1336,20 @@ async def test_render_template_with_error( ( "{{ my_unknown_func() + 1 }}", [ - {"type": "event", "event": VARIABLE_ERROR_UNDEFINED_FUNC}, - ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_1}, ], ), ( "{{ my_unknown_var }}", [ - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, {"type": "result", "success": True, "result": None}, - {"type": "event", "event": VARIABLE_WARNING_UNDEFINED_VAR}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_WARN}, { "type": "event", "event": {"result": "", "listeners": EMPTY_LISTENERS}, @@ -1343,11 +1358,19 @@ async def test_render_template_with_error( ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1386,19 +1409,35 @@ async def test_render_template_with_timeout_and_error( [ ( "{{ my_unknown_func() + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FUNC}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FUNC_2}, + ], ), ( "{{ my_unknown_var }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ my_unknown_var + 1 }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_VAR}], + [ + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_VAR_ERR}, + ], ), ( "{{ now() | unknown_filter }}", - [ERR_MSG | {"error": TEMPLATE_ERROR_UNDEFINED_FILTER}], + [ + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + {"type": "result", "success": True, "result": None}, + {"type": "event", "event": EVENT_UNDEFINED_FILTER}, + ], ), ], ) @@ -1409,7 +1448,10 @@ async def test_render_template_strict_with_timeout_and_error( template: str, expected_events: list[dict[str, str]], ) -> None: - """Test a template with an error with a timeout.""" + """Test a template with an error with a timeout. + + In this test report_errors is enabled. + """ caplog.set_level(logging.INFO) await websocket_client.send_json( { @@ -1418,6 +1460,7 @@ async def test_render_template_strict_with_timeout_and_error( "template": template, "timeout": 5, "strict": True, + "report_errors": True, } ) @@ -1432,25 +1475,221 @@ async def test_render_template_strict_with_timeout_and_error( assert "TemplateError" not in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events"), + [ + ( + "{{ my_unknown_func() + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ my_unknown_var + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ( + "{{ now() | unknown_filter }}", + [ + {"type": "result", "success": True, "result": None}, + ], + ), + ], +) +async def test_render_template_strict_with_timeout_and_error_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events: list[dict[str, str]], +) -> None: + """Test a template with an error with a timeout. + + In this test report_errors is disabled. + """ + caplog.set_level(logging.INFO) + await websocket_client.send_json( + { + "id": 5, + "type": "render_template", + "template": template, + "timeout": 5, + "strict": True, + } + ) + + for expected_event in expected_events: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "TemplateError" in caplog.text + + +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "TypeError: object of type 'datetime.datetime' has no len()", + "level": "ERROR", + }, + }, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + {"type": "result", "success": True, "result": None}, + { + "type": "event", + "event": { + "error": "UndefinedError: 'None' has no attribute 'state'", + "level": "ERROR", + }, + }, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) async def test_render_template_error_in_template_code( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], ) -> None: - """Test a template that will throw in template.py.""" + """Test a template that will throw in template.py. + + In this test report_errors is enabled. + """ await websocket_client.send_json( - {"id": 5, "type": "render_template", "template": "{{ now() | random }}"} + { + "id": 5, + "type": "render_template", + "template": template, + "report_errors": True, + } ) - msg = await websocket_client.receive_json() - assert msg["id"] == 5 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_TEMPLATE_ERROR + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value assert "Template variable error" not in caplog.text assert "Template variable warning" not in caplog.text assert "TemplateError" not in caplog.text +@pytest.mark.parametrize( + ("template", "expected_events_1", "expected_events_2"), + [ + ( + "{{ now() | random }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [], + ), + ( + "{{ float(states.sensor.foo.state) + 1 }}", + [ + {"type": "result", "success": True, "result": None}, + ], + [ + { + "type": "event", + "event": { + "result": 3.0, + "listeners": EMPTY_LISTENERS | {"entities": ["sensor.foo"]}, + }, + }, + ], + ), + ], +) +async def test_render_template_error_in_template_code_2( + hass: HomeAssistant, + websocket_client, + caplog: pytest.LogCaptureFixture, + template: str, + expected_events_1: list[dict[str, str]], + expected_events_2: list[dict[str, str]], +) -> None: + """Test a template that will throw in template.py. + + In this test report_errors is disabled. + """ + await websocket_client.send_json( + {"id": 5, "type": "render_template", "template": template} + ) + + for expected_event in expected_events_1: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + hass.states.async_set("sensor.foo", "2") + + for expected_event in expected_events_2: + msg = await websocket_client.receive_json() + assert msg["id"] == 5 + for key, value in expected_event.items(): + assert msg[key] == value + + assert "TemplateError" in caplog.text + + async def test_render_template_with_delayed_error( hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture ) -> None: diff --git a/tests/helpers/test_event.py b/tests/helpers/test_event.py index dc06b9d94c84e1..00ad580693e6eb 100644 --- a/tests/helpers/test_event.py +++ b/tests/helpers/test_event.py @@ -3239,27 +3239,6 @@ def refresh_listener( ] -async def test_async_track_template_result_raise_on_template_error( - hass: HomeAssistant, -) -> None: - """Test that we raise as soon as we encounter a failed template.""" - - with pytest.raises(TemplateError): - async_track_template_result( - hass, - [ - TrackTemplate( - Template( - "{{ states.switch | function_that_does_not_exist | list }}" - ), - None, - ), - ], - ha.callback(lambda event, updates: None), - raise_on_template_error=True, - ) - - async def test_track_template_with_time(hass: HomeAssistant) -> None: """Test tracking template with time.""" From d59aa958b6d6eda6d8aef3dc406edce1a7301459 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Fri, 8 Sep 2023 23:03:27 +0200 Subject: [PATCH 255/640] Add tests for Minecraft Server entry migration from v1 to v2 (#99954) --- tests/components/minecraft_server/const.py | 32 +++++ .../minecraft_server/test_config_flow.py | 47 ++----- .../components/minecraft_server/test_init.py | 131 ++++++++++++++++++ 3 files changed, 176 insertions(+), 34 deletions(-) create mode 100644 tests/components/minecraft_server/const.py create mode 100644 tests/components/minecraft_server/test_init.py diff --git a/tests/components/minecraft_server/const.py b/tests/components/minecraft_server/const.py new file mode 100644 index 00000000000000..3f635fbe333eb0 --- /dev/null +++ b/tests/components/minecraft_server/const.py @@ -0,0 +1,32 @@ +"""Constants for Minecraft Server integration tests.""" +from mcstatus.motd import Motd +from mcstatus.status_response import ( + JavaStatusPlayers, + JavaStatusResponse, + JavaStatusVersion, +) + +TEST_HOST = "mc.dummyserver.com" + +TEST_JAVA_STATUS_RESPONSE_RAW = { + "description": {"text": "Dummy Description"}, + "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 = JavaStatusResponse( + raw=TEST_JAVA_STATUS_RESPONSE_RAW, + players=JavaStatusPlayers.build(TEST_JAVA_STATUS_RESPONSE_RAW["players"]), + version=JavaStatusVersion.build(TEST_JAVA_STATUS_RESPONSE_RAW["version"]), + motd=Motd.parse(TEST_JAVA_STATUS_RESPONSE_RAW["description"], bedrock=False), + icon=None, + latency=5, +) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index d9e7d46a88c166..c4d8c72e32de4d 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -1,9 +1,8 @@ -"""Test the Minecraft Server config flow.""" +"""Tests for the Minecraft Server config flow.""" from unittest.mock import AsyncMock, patch import aiodns -from mcstatus.status_response import JavaStatusResponse from homeassistant.components.minecraft_server.const import ( DEFAULT_NAME, @@ -15,39 +14,27 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from .const import TEST_HOST, TEST_JAVA_STATUS_RESPONSE + class QueryMock: """Mock for result of aiodns.DNSResolver.query.""" def __init__(self) -> None: """Set up query result mock.""" - self.host = "mc.dummyserver.com" + self.host = TEST_HOST self.port = 23456 self.priority = 1 self.weight = 1 self.ttl = None -JAVA_STATUS_RESPONSE_RAW = { - "description": {"text": "Dummy Description"}, - "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"}, - ], - }, -} - USER_INPUT = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: f"mc.dummyserver.com:{DEFAULT_PORT}", + CONF_HOST: f"{TEST_HOST}:{DEFAULT_PORT}", } -USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: "dummyserver.com"} +USER_INPUT_SRV = {CONF_NAME: DEFAULT_NAME, CONF_HOST: TEST_HOST} USER_INPUT_IPV4 = { CONF_NAME: DEFAULT_NAME, @@ -61,12 +48,12 @@ def __init__(self) -> None: USER_INPUT_PORT_TOO_SMALL = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:1023", + CONF_HOST: f"{TEST_HOST}:1023", } USER_INPUT_PORT_TOO_LARGE = { CONF_NAME: DEFAULT_NAME, - CONF_HOST: "mc.dummyserver.com:65536", + CONF_HOST: f"{TEST_HOST}:65536", } @@ -129,9 +116,7 @@ async def test_connection_succeeded_with_srv_record(hass: HomeAssistant) -> None side_effect=AsyncMock(return_value=[QueryMock()]), ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_SRV @@ -150,9 +135,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT @@ -161,7 +144,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == USER_INPUT[CONF_HOST] assert result["data"][CONF_NAME] == USER_INPUT[CONF_NAME] - assert result["data"][CONF_HOST] == "mc.dummyserver.com" + assert result["data"][CONF_HOST] == TEST_HOST async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: @@ -171,9 +154,7 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV4 @@ -192,9 +173,7 @@ async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: side_effect=aiodns.error.DNSError, ), patch( "mcstatus.server.JavaServer.async_status", - return_value=JavaStatusResponse( - None, None, None, None, JAVA_STATUS_RESPONSE_RAW, None - ), + return_value=TEST_JAVA_STATUS_RESPONSE, ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, data=USER_INPUT_IPV6 diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py new file mode 100644 index 00000000000000..5bdce5ed9b7417 --- /dev/null +++ b/tests/components/minecraft_server/test_init.py @@ -0,0 +1,131 @@ +"""Tests for the Minecraft Server integration.""" +from unittest.mock import patch + +import aiodns + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.minecraft_server.const import ( + DEFAULT_NAME, + DEFAULT_PORT, + DOMAIN, +) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.const import 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_HOST, TEST_JAVA_STATUS_RESPONSE + +from tests.common import MockConfigEntry + +TEST_UNIQUE_ID = f"{TEST_HOST}-{DEFAULT_PORT}" + +SENSOR_KEYS = [ + {"v1": "Latency Time", "v2": "latency"}, + {"v1": "Players Max", "v2": "players_max"}, + {"v1": "Players Online", "v2": "players_online"}, + {"v1": "Protocol Version", "v2": "protocol_version"}, + {"v1": "Version", "v2": "version"}, + {"v1": "World Message", "v2": "motd"}, +] + +BINARY_SENSOR_KEYS = {"v1": "Status", "v2": "status"} + + +async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: + """Test entry migratiion from version 1 to 2.""" + + # Create mock config entry. + config_entry_v1 = MockConfigEntry( + domain=DOMAIN, + unique_id=TEST_UNIQUE_ID, + data={ + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + }, + version=1, + ) + config_entry_id = config_entry_v1.entry_id + config_entry_v1.add_to_hass(hass) + + # Create mock device entry. + device_registry = dr.async_get(hass) + device_entry_v1 = device_registry.async_get_or_create( + config_entry_id=config_entry_id, + identifiers={(DOMAIN, TEST_UNIQUE_ID)}, + ) + device_entry_id = device_entry_v1.id + assert device_entry_v1 + assert device_entry_id + + # Create mock sensor entity entries. + sensor_entity_id_key_mapping_list = [] + entity_registry = er.async_get(hass) + for sensor_key in SENSOR_KEYS: + entity_unique_id = f"{TEST_UNIQUE_ID}-{sensor_key['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + sensor_entity_id_key_mapping_list.append( + {"entity_id": entity_entry_v1.entity_id, "key": sensor_key["v2"]} + ) + + # Create mock binary sensor entity entry. + entity_unique_id = f"{TEST_UNIQUE_ID}-{BINARY_SENSOR_KEYS['v1']}" + entity_entry_v1 = entity_registry.async_get_or_create( + BINARY_SENSOR_DOMAIN, + DOMAIN, + unique_id=entity_unique_id, + config_entry=config_entry_v1, + device_id=device_entry_id, + ) + assert entity_entry_v1.unique_id == entity_unique_id + binary_sensor_entity_id_key_mapping = { + "entity_id": entity_entry_v1.entity_id, + "key": BINARY_SENSOR_KEYS["v2"], + } + + # Trigger migration. + with patch( + "aiodns.DNSResolver.query", + side_effect=aiodns.error.DNSError, + ), patch( + "mcstatus.server.JavaServer.async_status", + return_value=TEST_JAVA_STATUS_RESPONSE, + ): + assert await hass.config_entries.async_setup(config_entry_id) + await hass.async_block_till_done() + + # Test migrated config entry. + config_entry_v2 = hass.config_entries.async_get_entry(config_entry_id) + assert config_entry_v2.unique_id is None + assert config_entry_v2.data == { + CONF_NAME: DEFAULT_NAME, + CONF_HOST: TEST_HOST, + CONF_PORT: DEFAULT_PORT, + } + assert config_entry_v2.version == 2 + + # Test migrated device entry. + device_entry_v2 = device_registry.async_get(device_entry_id) + assert device_entry_v2.identifiers == {(DOMAIN, config_entry_id)} + + # Test migrated sensor entity entries. + for mapping in sensor_entity_id_key_mapping_list: + entity_entry_v2 = entity_registry.async_get(mapping["entity_id"]) + assert entity_entry_v2.unique_id == f"{config_entry_id}-{mapping['key']}" + + # Test migrated binary sensor entity entry. + entity_entry_v2 = entity_registry.async_get( + binary_sensor_entity_id_key_mapping["entity_id"] + ) + assert ( + entity_entry_v2.unique_id + == f"{config_entry_id}-{binary_sensor_entity_id_key_mapping['key']}" + ) From 75f923a86e1e3aa6dc6d9c3cfe6a8184de2b7137 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 01:16:51 +0200 Subject: [PATCH 256/640] Use device class translations for Devolo Update entity (#99235) --- homeassistant/components/devolo_home_network/update.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/devolo_home_network/update.py b/homeassistant/components/devolo_home_network/update.py index 21f6edd862c5f8..1c95c4262b2dc9 100644 --- a/homeassistant/components/devolo_home_network/update.py +++ b/homeassistant/components/devolo_home_network/update.py @@ -92,7 +92,6 @@ def __init__( """Initialize entity.""" self.entity_description = description super().__init__(entry, coordinator, device) - self._attr_translation_key = None self._in_progress_old_version: str | None = None @property @@ -124,7 +123,7 @@ async def async_install( except DevicePasswordProtected as ex: self.entry.async_start_reauth(self.hass) raise HomeAssistantError( - f"Device {self.entry.title} require re-authenticatication to set or change the password" + f"Device {self.entry.title} require re-authentication to set or change the password" ) from ex except DeviceUnavailable as ex: raise HomeAssistantError( From e163e00acd6f396472c094b5e7ef1cfa85b46c3e Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 01:51:26 +0200 Subject: [PATCH 257/640] Update RestrictedPython to 6.2 (#99955) --- homeassistant/components/python_script/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/python_script/manifest.json b/homeassistant/components/python_script/manifest.json index ea153be11cf1f9..80ed6164e74b06 100644 --- a/homeassistant/components/python_script/manifest.json +++ b/homeassistant/components/python_script/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/python_script", "loggers": ["RestrictedPython"], "quality_scale": "internal", - "requirements": ["RestrictedPython==6.1"] + "requirements": ["RestrictedPython==6.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1380ebbfa8a2d3..76c13000777068 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -122,7 +122,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c785034cc70b70..d0de16d9475a84 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -109,7 +109,7 @@ PyXiaomiGateway==0.14.3 RachioPy==1.0.3 # homeassistant.components.python_script -RestrictedPython==6.1 +RestrictedPython==6.2 # homeassistant.components.remember_the_milk RtmAPI==0.7.2 From 694638cbc05c9d793288092f4d565637c69d0c56 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 19:39:30 -0500 Subject: [PATCH 258/640] Bump bleak to 0.21.1 (#99960) --- 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 a3c40f739aac91..393326d2687afd 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -14,7 +14,7 @@ ], "quality_scale": "internal", "requirements": [ - "bleak==0.21.0", + "bleak==0.21.1", "bleak-retry-connector==3.1.3", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.2", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 59005c7bf49f25..f67d82da22e387 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -9,7 +9,7 @@ attrs==23.1.0 awesomeversion==22.9.0 bcrypt==4.0.1 bleak-retry-connector==3.1.3 -bleak==0.21.0 +bleak==0.21.1 bluetooth-adapters==0.16.1 bluetooth-auto-recovery==1.2.2 bluetooth-data-tools==1.11.0 diff --git a/requirements_all.txt b/requirements_all.txt index 76c13000777068..6942bba1eadf9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bizkaibus==0.1.1 bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth -bleak==0.21.0 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d0de16d9475a84..41eaa6f7fc8d81 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -440,7 +440,7 @@ bimmer-connected==0.14.0 bleak-retry-connector==3.1.3 # homeassistant.components.bluetooth -bleak==0.21.0 +bleak==0.21.1 # homeassistant.components.blebox blebox-uniapi==2.1.4 From f903cd6fc07d68ab589374e9239656b9c966dd77 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 8 Sep 2023 21:16:21 -0500 Subject: [PATCH 259/640] Bump dbus-fast to 2.0.1 (#99894) --- 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 393326d2687afd..d6753adf3c4cd1 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.2", "bluetooth-data-tools==1.11.0", - "dbus-fast==1.95.2" + "dbus-fast==2.0.1" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f67d82da22e387..3c6be4df133b51 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==1.95.2 +dbus-fast==2.0.1 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 6942bba1eadf9a..6462cbda3b8784 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.0.1 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 41eaa6f7fc8d81..9b021aad92ded7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -523,7 +523,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==1.95.2 +dbus-fast==2.0.1 # homeassistant.components.debugpy debugpy==1.6.7 From cf47a6c515caf37a3ef0562509d6f86572ca5469 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Sat, 9 Sep 2023 11:12:44 +0200 Subject: [PATCH 260/640] Add UniFi device uptime and temperature sensors (#99307) * Add UniFi device uptime and temperature sensors * Add native_unit_of_measurement to temperature Remove seconds and milliseconds from device uptime --- homeassistant/components/unifi/sensor.py | 50 ++++++++++- tests/components/unifi/test_sensor.py | 107 ++++++++++++++++++++++- 2 files changed, 153 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 142bd587853a74..7cb0b2bbfe3d6e 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -27,6 +27,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + UnitOfTemperature, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfPower @@ -88,6 +89,16 @@ def async_wlan_client_value_fn(controller: UniFiController, wlan: Wlan) -> int: ) +@callback +def async_device_uptime_value_fn( + controller: UniFiController, device: Device +) -> datetime: + """Calculate the uptime of the device.""" + return (dt_util.now() - timedelta(seconds=device.uptime)).replace( + second=0, microsecond=0 + ) + + @callback def async_device_outlet_power_supported_fn( controller: UniFiController, obj_id: str @@ -178,7 +189,7 @@ class UnifiSensorEntityDescription( value_fn=lambda _, obj: obj.poe_power if obj.poe_mode != "off" else "0", ), UnifiSensorEntityDescription[Clients, Client]( - key="Uptime sensor", + key="Client uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, has_entity_name=True, @@ -272,6 +283,43 @@ class UnifiSensorEntityDescription( unique_id_fn=lambda controller, obj_id: f"ac_power_conumption-{obj_id}", value_fn=lambda controller, device: device.outlet_ac_power_consumption, ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Uptime", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda controller, obj_id: True, + unique_id_fn=lambda controller, obj_id: f"device_uptime-{obj_id}", + value_fn=async_device_uptime_value_fn, + ), + UnifiSensorEntityDescription[Devices, Device]( + key="Device temperature", + device_class=SensorDeviceClass.TEMPERATURE, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + has_entity_name=True, + allowed_fn=lambda controller, obj_id: True, + api_handler_fn=lambda api: api.devices, + available_fn=async_device_available_fn, + device_info_fn=async_device_device_info_fn, + event_is_on=None, + event_to_subscribe=None, + name_fn=lambda device: "Temperature", + object_fn=lambda api, obj_id: api.devices[obj_id], + should_poll=False, + supported_fn=lambda ctrlr, obj_id: ctrlr.api.devices[obj_id].has_temperature, + unique_id_fn=lambda controller, obj_id: f"device_temperature-{obj_id}", + value_fn=lambda ctrlr, device: device.general_temperature, + ), ) diff --git a/tests/components/unifi/test_sensor.py b/tests/components/unifi/test_sensor.py index 7ed87512f2bad7..7b6a3bc1edc6a7 100644 --- a/tests/components/unifi/test_sensor.py +++ b/tests/components/unifi/test_sensor.py @@ -566,7 +566,7 @@ async def test_poe_port_switches( ) -> None: """Test the update_items function with some clients.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[DEVICE_1]) - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 0 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get("sensor.mock_name_port_1_poe_power") @@ -788,8 +788,8 @@ async def test_outlet_power_readings( """Test the outlet power reporting on PDU devices.""" await setup_unifi_integration(hass, aioclient_mock, devices_response=[PDU_DEVICE_1]) - assert len(hass.states.async_all()) == 9 - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 + assert len(hass.states.async_all()) == 10 + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 4 ent_reg = er.async_get(hass) ent_reg_entry = ent_reg.async_get(f"sensor.{entity_id}") @@ -809,3 +809,104 @@ async def test_outlet_power_readings( sensor_data = hass.states.get(f"sensor.{entity_id}") assert sensor_data.state == expected_update_value + + +async def test_device_uptime( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that uptime sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + now = datetime(2021, 1, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 1 + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_uptime").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify normal new event doesn't change uptime + # 4 seconds has passed + + device["uptime"] = 64 + now = datetime(2021, 1, 1, 1, 1, 4, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-01-01T01:00:00+00:00" + + # Verify new event change uptime + # 1 month has passed + + device["uptime"] = 60 + now = datetime(2021, 2, 1, 1, 1, 0, tzinfo=dt_util.UTC) + with patch("homeassistant.util.dt.now", return_value=now): + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + + assert hass.states.get("sensor.device_uptime").state == "2021-02-01T01:00:00+00:00" + + +async def test_device_temperature( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket +) -> None: + """Verify that temperature sensors are working as expected.""" + device = { + "board_rev": 3, + "device_id": "mock-id", + "general_temperature": 30, + "has_fan": True, + "has_temperature": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "uptime": 60, + "version": "4.0.42.10433", + } + + await setup_unifi_integration(hass, aioclient_mock, devices_response=[device]) + assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 2 + assert hass.states.get("sensor.device_temperature").state == "30" + + ent_reg = er.async_get(hass) + assert ( + ent_reg.async_get("sensor.device_temperature").entity_category + is EntityCategory.DIAGNOSTIC + ) + + # Verify new event change temperature + device["general_temperature"] = 60 + mock_unifi_websocket(message=MessageKey.DEVICE, data=device) + await hass.async_block_till_done() + assert hass.states.get("sensor.device_temperature").state == "60" From dced72f2ddbba3022471c4b5fc96f6c194a76bc7 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 08:15:28 -0400 Subject: [PATCH 261/640] Bump python-roborock to 33.2 (#99962) bump to 33.2 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 01548a6334c636..dfcac67d2b0589 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.32.3"] + "requirements": ["python-roborock==0.33.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6462cbda3b8784..5341de900da8e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2160,7 +2160,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.33.2 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b021aad92ded7..da11cbb5775062 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1589,7 +1589,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.32.3 +python-roborock==0.33.2 # homeassistant.components.smarttub python-smarttub==0.0.33 From 483e9c92bd47271f1fb4fd070d27129678f2971d Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 14:53:25 +0200 Subject: [PATCH 262/640] Update black to 23.9.0 (#99965) --- .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 77740d6279e72b..50829592f53c31 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.7.0 + rev: 23.9.0 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 844d796e7af72a..9663d0a8fb7f4e 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.7.0 +black==23.9.0 codespell==2.2.2 ruff==0.0.285 yamllint==1.32.0 From c77eb708861fbe0feea07a9a0fc7c246eb7afb33 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:36:47 +0200 Subject: [PATCH 263/640] Add black caching [ci] (#99967) Co-authored-by: J. Nick Koston --- .github/workflows/ci.yaml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 2e1df49549efa7..c20886f23425fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -36,6 +36,7 @@ env: CACHE_VERSION: 5 PIP_CACHE_VERSION: 4 MYPY_CACHE_VERSION: 4 + BLACK_CACHE_VERSION: 1 HA_SHORT_VERSION: "2023.10" DEFAULT_PYTHON: "3.11" ALL_PYTHON_VERSIONS: "['3.11']" @@ -55,6 +56,7 @@ env: POSTGRESQL_VERSIONS: "['postgres:12.14','postgres:15.2']" PRE_COMMIT_CACHE: ~/.cache/pre-commit PIP_CACHE: /tmp/pip-cache + BLACK_CACHE: /tmp/black-cache SQLALCHEMY_WARN_20: 1 PYTHONASYNCIODEBUG: 1 HASS_CI: 1 @@ -272,6 +274,13 @@ jobs: with: python-version: ${{ env.DEFAULT_PYTHON }} check-latest: true + - name: Generate partial black restore key + id: generate-black-key + run: | + black_version=$(cat requirements_test_pre_commit.txt | grep black | cut -d '=' -f 3) + echo "version=$black_version" >> $GITHUB_OUTPUT + echo "key=black-${{ env.BLACK_CACHE_VERSION }}-$black_version-${{ + env.HA_SHORT_VERSION }}-$(date -u '+%Y-%m-%dT%H:%M:%s')" >> $GITHUB_OUTPUT - name: Restore base Python virtual environment id: cache-venv uses: actions/cache/restore@v3.3.2 @@ -290,14 +299,28 @@ jobs: key: >- ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ needs.info.outputs.pre-commit_cache_key }} + - name: Restore black cache + uses: actions/cache@v3.3.2 + with: + path: ${{ env.BLACK_CACHE }} + key: >- + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-${{ + steps.generate-black-key.outputs.key }} + restore-keys: | + ${{ runner.os }}-${{ steps.python.outputs.python-version }}-black-${{ + env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ + env.HA_SHORT_VERSION }}- - name: Run black (fully) - if: needs.info.outputs.test_full_suite == 'true' + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate pre-commit run --hook-stage manual black --all-files --show-diff-on-failure - name: Run black (partially) if: needs.info.outputs.test_full_suite == 'false' shell: bash + env: + BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | . venv/bin/activate shopt -s globstar From 74a7bccd659b91e2a1fda1947422b19a35015fae Mon Sep 17 00:00:00 2001 From: Thomas Roager <33527165+Roagert@users.noreply.github.com> Date: Sat, 9 Sep 2023 16:01:32 +0200 Subject: [PATCH 264/640] Add zdb5100 light to zwave_js (#97586) * added zdb5100 light * added light to zdb5100 * Update tests/components/zwave_js/conftest.py agree Co-authored-by: Martin Hjelmare * Update tests/components/zwave_js/conftest.py agree Co-authored-by: Martin Hjelmare * Rename logic_group_zdb5100_light_state.json to logic_group_zdb5100_state.json name change * Update tests/components/zwave_js/test_light.py Co-authored-by: Martin Hjelmare * Update test_light.py updated test and state * Update test_light.py incorrect endpoint * changed the state --------- Co-authored-by: Martin Hjelmare --- .../components/zwave_js/discovery.py | 13 + tests/components/zwave_js/conftest.py | 14 + .../fixtures/logic_group_zdb5100_state.json | 4691 +++++++++++++++++ tests/components/zwave_js/test_light.py | 178 + 4 files changed, 4896 insertions(+) create mode 100644 tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index c879cc1f5b459a..d54dc659be1e46 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -588,6 +588,19 @@ class ZWaveDiscoverySchema: ), absent_values=[SWITCH_MULTILEVEL_CURRENT_VALUE_SCHEMA], ), + # Logic Group ZDB5100 + ZWaveDiscoverySchema( + platform=Platform.LIGHT, + hint="black_is_off", + manufacturer_id={0x0234}, + product_id={0x0121}, + product_type={0x0003}, + primary_value=ZWaveValueDiscoverySchema( + command_class={CommandClass.SWITCH_COLOR}, + property={CURRENT_COLOR_PROPERTY}, + property_key={None}, + ), + ), # ====== START OF GENERIC MAPPING SCHEMAS ======= # locks # Door Lock CC diff --git a/tests/components/zwave_js/conftest.py b/tests/components/zwave_js/conftest.py index dcd847a6e12dc3..e950ff0402c866 100644 --- a/tests/components/zwave_js/conftest.py +++ b/tests/components/zwave_js/conftest.py @@ -650,6 +650,12 @@ def nice_ibt4zwave_state_fixture(): return json.loads(load_fixture("zwave_js/cover_nice_ibt4zwave_state.json")) +@pytest.fixture(name="logic_group_zdb5100_state", scope="session") +def logic_group_zdb5100_state_fixture(): + """Load the Logic Group ZDB5100 node state fixture data.""" + return json.loads(load_fixture("zwave_js/logic_group_zdb5100_state.json")) + + # model fixtures @@ -1262,3 +1268,11 @@ def nice_ibt4zwave_fixture(client, nice_ibt4zwave_state): node = Node(client, copy.deepcopy(nice_ibt4zwave_state)) client.driver.controller.nodes[node.node_id] = node return node + + +@pytest.fixture(name="logic_group_zdb5100") +def logic_group_zdb5100_fixture(client, logic_group_zdb5100_state): + """Mock a ZDB5100 light node.""" + node = Node(client, copy.deepcopy(logic_group_zdb5100_state)) + client.driver.controller.nodes[node.node_id] = node + return node diff --git a/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json new file mode 100644 index 00000000000000..b570e9cea34909 --- /dev/null +++ b/tests/components/zwave_js/fixtures/logic_group_zdb5100_state.json @@ -0,0 +1,4691 @@ +{ + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "status": 4, + "ready": true, + "isListening": true, + "isRouting": true, + "isSecure": false, + "manufacturerId": 564, + "productId": 289, + "productType": 3, + "firmwareVersion": "1.8.0", + "zwavePlusVersion": 1, + "name": "matrix_office", + "location": "**REDACTED**", + "deviceConfig": { + "filename": "/usr/src/app/store/config/zdb5100.json", + "isEmbedded": false, + "manufacturer": "Logic Group", + "manufacturerId": 564, + "label": "ZDB5100", + "description": "Wall Controller", + "devices": [ + { + "productType": 3, + "productId": 289 + } + ], + "firmwareVersion": { + "min": "0.0", + "max": "255.255" + }, + "preferred": false, + "endpoints": {}, + "paramInformation": { + "_map": {} + }, + "compat": { + "disableBasicMapping": true + }, + "metadata": { + "inclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "exclusion": "Remove white button cover and press on the center switch with a non-conductive object. The LEDs will now start blinking on button 1 (upper left button)", + "reset": "Remove white button cover and long-press the center switch for 10 seconds with a non-conductive object. Please use this procedure only when the network primary controller is missing or otherwise inoperable", + "manual": "https://products.z-wavealliance.org/ProductManual/File?folder=&filename=MarketCertificationFiles/3399/MATRIX_ZDB5100_User_Manual_1_01-EN.pdf" + } + }, + "label": "ZDB5100", + "endpointCountIsDynamic": false, + "endpointsHaveIdenticalCapabilities": false, + "individualEndpointCount": 5, + "aggregatedEndpointCount": 0, + "interviewAttempts": 1, + "endpoints": [ + { + "nodeId": 116, + "index": 0, + "installerIcon": 5632, + "userIcon": 5632, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 85, + "name": "Transport Service", + "version": 2, + "isSecure": false + }, + { + "id": 134, + "name": "Version", + "version": 3, + "isSecure": false + }, + { + "id": 114, + "name": "Manufacturer Specific", + "version": 2, + "isSecure": false + }, + { + "id": 90, + "name": "Device Reset Locally", + "version": 1, + "isSecure": false + }, + { + "id": 115, + "name": "Powerlevel", + "version": 1, + "isSecure": false + }, + { + "id": 152, + "name": "Security", + "version": 1, + "isSecure": true + }, + { + "id": 159, + "name": "Security 2", + "version": 1, + "isSecure": true + }, + { + "id": 96, + "name": "Multi Channel", + "version": 4, + "isSecure": false + }, + { + "id": 112, + "name": "Configuration", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 122, + "name": "Firmware Update Meta Data", + "version": 4, + "isSecure": false + }, + { + "id": 91, + "name": "Central Scene", + "version": 3, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 1, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 2, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 3, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 4, + "installerIcon": 7168, + "userIcon": 7172, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "isSecure": false + }, + { + "id": 51, + "name": "Color Switch", + "version": 1, + "isSecure": false + } + ] + }, + { + "nodeId": 116, + "index": 5, + "installerIcon": 1536, + "userIcon": 1537, + "deviceClass": { + "basic": { + "key": 4, + "label": "Routing Slave" + }, + "generic": { + "key": 17, + "label": "Multilevel Switch" + }, + "specific": { + "key": 1, + "label": "Multilevel Power Switch" + }, + "mandatorySupportedCCs": [32, 38, 39], + "mandatoryControlledCCs": [] + }, + "commandClasses": [ + { + "id": 32, + "name": "Basic", + "version": 2, + "isSecure": false + }, + { + "id": 38, + "name": "Multilevel Switch", + "version": 4, + "isSecure": false + }, + { + "id": 94, + "name": "Z-Wave Plus Info", + "version": 2, + "isSecure": false + }, + { + "id": 133, + "name": "Association", + "version": 2, + "isSecure": false + }, + { + "id": 89, + "name": "Association Group Information", + "version": 1, + "isSecure": false + }, + { + "id": 142, + "name": "Multi Channel Association", + "version": 3, + "isSecure": false + }, + { + "id": 108, + "name": "Supervision", + "version": 1, + "isSecure": false + }, + { + "id": 37, + "name": "Binary Switch", + "version": 1, + "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": "scene", + "propertyKey": "003", + "propertyName": "scene", + "propertyKeyName": "003", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 003", + "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": "004", + "propertyName": "scene", + "propertyKeyName": "004", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Scene 004", + "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": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 1, + "propertyName": "Button 1", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 2, + "propertyName": "Button 2", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 4, + "propertyName": "Button 3", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 1, + "propertyKey": 8, + "propertyName": "Button 4", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 2, + "propertyName": "Duration of Dimming", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of Dimming", + "default": 5, + "min": 0, + "max": 255, + "unit": "seconds", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of Dimming" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 3, + "propertyName": "Duration of On/Off", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Duration of On/Off", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Duration of On/Off" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 4, + "propertyName": "Dimmer Mode", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer Mode", + "default": 1, + "min": 0, + "max": 2, + "states": { + "0": "Switch only", + "1": "Trailing edge", + "2": "Leading edge" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Dimmer Mode" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 5, + "propertyName": "Dimmer: Minimum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Minimum Level", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Minimum Level" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 6, + "propertyName": "Dimmer: Maximum Level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Dimmer: Maximum Level", + "default": 99, + "min": 0, + "max": 99, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Dimmer: Maximum Level" + }, + "value": 99 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 7, + "propertyName": "Central Scene", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Central Scene", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Central Scene" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 8, + "propertyName": "Double Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Double Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Double Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 10, + "propertyName": "Enhanced LED Control", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Enhanced LED Control", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Enhanced LED Control" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 11, + "propertyName": "Button Debounce Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Debounce Timer", + "default": 5, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Debounce Timer" + }, + "value": 5 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 12, + "propertyName": "Button Press Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Press Threshold Time", + "default": 20, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Press Threshold Time" + }, + "value": 20 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 13, + "propertyName": "Button Held Threshold Time", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button Held Threshold Time", + "default": 50, + "min": 1, + "max": 255, + "unit": "10 ms", + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button Held Threshold Time" + }, + "value": 50 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 4278190080, + "propertyName": "LED Indicator Brightness: Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Red", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 16711680, + "propertyName": "LED Indicator Brightness: Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Green", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Green" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 14, + "propertyKey": 65280, + "propertyName": "LED Indicator Brightness: Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Indicator Brightness: Blue", + "default": 255, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Indicator Brightness: Blue" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1, + "propertyName": "Send Association Group 2 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 2 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 2 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2, + "propertyName": "Send Association Group 3 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 3 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 3 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4, + "propertyName": "Send Association Group 4 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 4 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 4 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 8, + "propertyName": "Send Association Group 5 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 5 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 5 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 16, + "propertyName": "Send Association Group 6 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 6 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 6 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 32, + "propertyName": "Send Association Group 7 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 7 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 7 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 64, + "propertyName": "Send Association Group 8 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 8 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 8 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 128, + "propertyName": "Send Association Group 9 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 9 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 9 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 256, + "propertyName": "Send Association Group 10 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 10 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 10 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 512, + "propertyName": "Send Association Group 11 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 11 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 11 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 1024, + "propertyName": "Send Association Group 12 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 12 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 12 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 2048, + "propertyName": "Send Association Group 13 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 13 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 13 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 15, + "propertyKey": 4096, + "propertyName": "Send Association Group 14 Messages Securely", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Send Association Group 14 Messages Securely", + "default": 0, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 2, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Send Association Group 14 Messages Securely" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 16, + "propertyName": "Button 1 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 17, + "propertyName": "Button 1 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 4278190080, + "propertyName": "Button 1 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 16711680, + "propertyName": "Button 1 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 18, + "propertyKey": 65280, + "propertyName": "Button 1 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 19, + "propertyName": "Button 1 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 20, + "propertyName": "Button 1 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 21, + "propertyName": "Button 1 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 1 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 22, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 4278190080, + "propertyName": "Button 1 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 16711680, + "propertyName": "Button 1 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 65280, + "propertyName": "Button 1 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 1 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 1 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 23, + "propertyKey": 255, + "propertyName": "LED Time For Button 1 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 1 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 1 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 24, + "propertyName": "Button 2 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 25, + "propertyName": "Button 2 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 4278190080, + "propertyName": "Button 2 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 16711680, + "propertyName": "Button 2 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 26, + "propertyKey": 65280, + "propertyName": "Button 2 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 27, + "propertyName": "Button 2 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 28, + "propertyName": "Button 2 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 29, + "propertyName": "Button 2 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 2 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 30, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 4278190080, + "propertyName": "Button 2 LED Indicator (Off) Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 16711680, + "propertyName": "Button 2 LED Indicator (Off) Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 65280, + "propertyName": "Button 2 LED Indicator (Off) Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 2 LED Indicator (Off) Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 2 LED Indicator (Off) Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 31, + "propertyKey": 255, + "propertyName": "LED Time For Button 2 (Off) Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 2 (Off) Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 2 (Off) Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 32, + "propertyName": "Button 3 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 33, + "propertyName": "Button 3 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 4278190080, + "propertyName": "Button 3 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 16711680, + "propertyName": "Button 3 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 34, + "propertyKey": 65280, + "propertyName": "Button 3 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 35, + "propertyName": "Button 3 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 36, + "propertyName": "Button 3 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 37, + "propertyName": "Button 3 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 3 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 38, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (On): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 4278190080, + "propertyName": "Button 3 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 16711680, + "propertyName": "Button 3 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 65280, + "propertyName": "Button 3 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 3 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 3 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 39, + "propertyKey": 255, + "propertyName": "LED Time For Button 3 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 3 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 3 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 40, + "propertyName": "Button 4 Functionality", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 Functionality", + "default": 0, + "min": 0, + "max": 4, + "states": { + "0": "Toggle", + "1": "Automatic turn off after time expired", + "2": "Automatic turn on after time expired", + "3": "Always turn off or dim down", + "4": "Always turn on or dim up" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 Functionality" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 41, + "propertyName": "Button 4 - Timer", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Timer", + "default": 300, + "min": 0, + "max": 43200, + "unit": "seconds", + "valueSize": 2, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Timer" + }, + "value": 300 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 4278190080, + "propertyName": "Button 4 - Single Press", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press", + "default": 1, + "min": 0, + "max": 1, + "states": { + "0": "Disable", + "1": "Enable" + }, + "valueSize": 4, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Single Press" + }, + "value": 1 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 16711680, + "propertyName": "Button 4 - Single Press (On Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (On Value)", + "default": 255, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (On Value)" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 42, + "propertyKey": 65280, + "propertyName": "Button 4 - Single Press (Off Value)", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Single Press (Off Value)", + "default": 0, + "min": 0, + "max": 99, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 - Single Press (Off Value)" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 43, + "propertyName": "Button 4 - Binary Switch Support", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 - Binary Switch Support", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "LED", + "1": "Switch and LED", + "2": "Button activated" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 - Binary Switch Support" + }, + "value": 2 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 44, + "propertyName": "Button 4 LED Indicator", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator", + "default": 7, + "min": 0, + "max": 7, + "states": { + "0": "Disable", + "1": "Follow switch", + "2": "Follow switch - inverted", + "5": "Follow internal dimmer", + "6": "Follow internal dimmer - inverted", + "7": "On for 5 seconds" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 45, + "propertyName": "Button 4 LED Indicator Color Commands", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator Color Commands", + "default": 0, + "min": 0, + "max": 2, + "states": { + "0": "Direct control", + "1": "Color for off state", + "2": "Color for on state" + }, + "valueSize": 1, + "format": 1, + "allowManualEntry": false, + "isFromConfig": true, + "name": "Button 4 LED Indicator Color Commands" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (On): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Red", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Red" + }, + "value": 255 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (On): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Green", + "default": 0, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (On): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (On): Blue", + "default": 127, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (On): Blue" + }, + "value": 234 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 46, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (On): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (On): Blinking", + "default": 1, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (On): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 4278190080, + "propertyName": "Button 4 LED Indicator (Off): Red", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Red", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Red" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 16711680, + "propertyName": "Button 4 LED Indicator (Off): Green", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Green", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Green" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 65280, + "propertyName": "Button 4 LED Indicator (Off): Blue", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Button 4 LED Indicator (Off): Blue", + "default": 47, + "min": 0, + "max": 255, + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "Button 4 LED Indicator (Off): Blue" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 47, + "propertyKey": 255, + "propertyName": "LED Time For Button 4 (Off): Blinking", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "LED Time For Button 4 (Off): Blinking", + "default": 0, + "min": 0, + "max": 255, + "unit": "100 ms", + "valueSize": 4, + "format": 1, + "allowManualEntry": true, + "isFromConfig": true, + "name": "LED Time For Button 4 (Off): Blinking" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 112, + "commandClassName": "Configuration", + "property": 9, + "propertyName": "Dimmer on level", + "ccVersion": 3, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "", + "label": "Dimmer on level", + "default": 0, + "min": 0, + "max": 227, + "valueSize": 1, + "format": 1, + "allowManualEntry": true, + "isFromConfig": false, + "name": "Dimmer on level", + "info": "" + }, + "value": 0 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "manufacturerId", + "propertyName": "manufacturerId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Manufacturer ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 564 + }, + { + "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": 3 + }, + { + "endpoint": 0, + "commandClass": 114, + "commandClassName": "Manufacturer Specific", + "property": "productId", + "propertyName": "productId", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Product ID", + "min": 0, + "max": 65535, + "stateful": true, + "secret": false + }, + "value": 289 + }, + { + "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": "5.3" + }, + { + "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.8"] + }, + { + "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": 2 + }, + { + "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": "6.71.3" + }, + { + "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": "3.1.1" + }, + { + "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": 52445 + }, + { + "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": "5.3.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": 43 + }, + { + "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.8.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": 1 + }, + { + "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": false + }, + { + "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 + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 1, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + } + }, + { + "endpoint": 2, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 3, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "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 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 2, + "propertyName": "currentColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Red channel.", + "label": "Current value (Red)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 3, + "propertyName": "currentColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Green channel.", + "label": "Current value (Green)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyKey": 4, + "propertyName": "currentColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "description": "The current value of the Blue channel.", + "label": "Current value (Blue)", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "currentColor", + "propertyName": "currentColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": false, + "label": "Current color", + "stateful": true, + "secret": false + }, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "hexColor", + "propertyName": "hexColor", + "ccVersion": 1, + "metadata": { + "type": "color", + "readable": true, + "writeable": true, + "label": "RGB Color", + "valueChangeOptions": ["transitionDuration"], + "minLength": 6, + "maxLength": 7, + "stateful": true, + "secret": false + }, + "value": "000000" + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 2, + "propertyName": "targetColor", + "propertyKeyName": "Red", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Red channel.", + "label": "Target value (Red)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 3, + "propertyName": "targetColor", + "propertyKeyName": "Green", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Green channel.", + "label": "Target value (Green)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyKey": 4, + "propertyName": "targetColor", + "propertyKeyName": "Blue", + "ccVersion": 1, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "description": "The target value of the Blue channel.", + "label": "Target value (Blue)", + "valueChangeOptions": ["transitionDuration"], + "min": 0, + "max": 255, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "targetColor", + "propertyName": "targetColor", + "ccVersion": 1, + "metadata": { + "type": "any", + "readable": true, + "writeable": true, + "label": "Target color", + "valueChangeOptions": ["transitionDuration"], + "stateful": true, + "secret": false + }, + "nodeId": 116, + "value": { + "red": 0, + "green": 0, + "blue": 0 + } + }, + { + "endpoint": 4, + "commandClass": 51, + "commandClassName": "Color Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 1, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "currentValue", + "propertyName": "currentValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": false, + "label": "Current value", + "min": 0, + "max": 99, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "targetValue", + "propertyName": "targetValue", + "ccVersion": 2, + "metadata": { + "type": "number", + "readable": true, + "writeable": true, + "label": "Target value", + "min": 0, + "max": 255, + "stateful": true, + "secret": false + }, + "value": 0 + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "duration", + "propertyName": "duration", + "ccVersion": 2, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "commandClass": 32, + "commandClassName": "Basic", + "property": "restorePrevious", + "propertyName": "restorePrevious", + "ccVersion": 2, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Restore previous value", + "states": { + "true": "Restore" + }, + "stateful": true, + "secret": false + } + }, + { + "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": 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": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "duration", + "propertyName": "duration", + "ccVersion": 4, + "metadata": { + "type": "duration", + "readable": true, + "writeable": false, + "label": "Remaining duration", + "stateful": true, + "secret": false + }, + "value": { + "value": 0, + "unit": "seconds" + } + }, + { + "endpoint": 5, + "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": 5, + "commandClass": 38, + "commandClassName": "Multilevel Switch", + "property": "Up", + "propertyName": "Up", + "ccVersion": 4, + "metadata": { + "type": "boolean", + "readable": false, + "writeable": true, + "label": "Perform a level change (Up)", + "ccSpecific": { + "switchType": 2 + }, + "valueChangeOptions": ["transitionDuration"], + "states": { + "true": "Start", + "false": "Stop" + }, + "stateful": true, + "secret": false + } + }, + { + "endpoint": 5, + "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": 5, + "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": 24, + "label": "Wall Controller" + }, + "specific": { + "key": 1, + "label": "Basic Wall Controller" + }, + "mandatorySupportedCCs": [], + "mandatoryControlledCCs": [] + }, + "interviewStage": "Complete", + "deviceDatabaseUrl": "https://devices.zwave-js.io/?jumpTo=0x0234:0x0003:0x0121:1.8.0", + "statistics": { + "commandsTX": 416, + "commandsRX": 415, + "commandsDroppedRX": 0, + "commandsDroppedTX": 0, + "timeoutResponse": 0, + "rtt": 29.4, + "lastSeen": "2023-08-20T09:41:00.683Z", + "rssi": -71, + "lwr": { + "protocolDataRate": 3, + "repeaters": [], + "rssi": -71, + "repeaterRSSI": [] + } + }, + "highestSecurityClass": -1, + "isControllerNode": false, + "keepAwake": false +} diff --git a/tests/components/zwave_js/test_light.py b/tests/components/zwave_js/test_light.py index 3a862ee3a0c427..4b0345b00ead66 100644 --- a/tests/components/zwave_js/test_light.py +++ b/tests/components/zwave_js/test_light.py @@ -35,6 +35,7 @@ ) HSM200_V1_ENTITY = "light.hsm200" +ZDB5100_ENTITY = "light.matrix_office" async def test_light( @@ -681,3 +682,180 @@ async def test_black_is_off( "property": "targetColor", } assert args["value"] == {"red": 255, "green": 76, "blue": 255} + + +async def test_black_is_off_zdb5100( + hass: HomeAssistant, client, logic_group_zdb5100, integration +) -> None: + """Test the black is off light entity.""" + node = logic_group_zdb5100 + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Attempt to turn on the light and ensure it defaults to white + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 255, "blue": 255} + + client.async_send_command.reset_mock() + + # Force the light to turn off + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_OFF + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "prevValue": { + "red": 0, + "green": 0, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_ON + + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 0, "blue": 0} + + client.async_send_command.reset_mock() + + # Assert that the last color is restored + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 0, "green": 255, "blue": 0} + + client.async_send_command.reset_mock() + + # Force the light to turn on + event = Event( + type="value updated", + data={ + "source": "node", + "event": "value updated", + "nodeId": node.node_id, + "args": { + "commandClassName": "Color Switch", + "commandClass": 51, + "endpoint": 1, + "property": "currentColor", + "newValue": None, + "prevValue": { + "red": 0, + "green": 255, + "blue": 0, + }, + "propertyName": "currentColor", + }, + }, + ) + node.receive_event(event) + await hass.async_block_till_done() + state = hass.states.get(ZDB5100_ENTITY) + assert state.state == STATE_UNKNOWN + + client.async_send_command.reset_mock() + + # Assert that call fails if attribute is added to service call + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: ZDB5100_ENTITY, ATTR_RGBW_COLOR: (255, 76, 255, 0)}, + blocking=True, + ) + assert len(client.async_send_command.call_args_list) == 1 + args = client.async_send_command.call_args_list[0][0][0] + assert args["command"] == "node.set_value" + assert args["nodeId"] == node.node_id + assert args["valueId"] == { + "commandClass": 51, + "endpoint": 1, + "property": "targetColor", + } + assert args["value"] == {"red": 255, "green": 76, "blue": 255} From e4256624948c39f8d2ecb909bec335a269f0246d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 9 Sep 2023 16:18:47 +0200 Subject: [PATCH 265/640] Bump pytrafikverket to 0.3.6 (#99869) * Bump pytrafikverket to 0.3.6 * Fix config flow names * str --- .../trafikverket_camera/config_flow.py | 29 ++++++++++++------- .../trafikverket_camera/manifest.json | 2 +- .../trafikverket_ferry/manifest.json | 2 +- .../trafikverket_train/manifest.json | 2 +- .../trafikverket_weatherstation/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../trafikverket_camera/test_config_flow.py | 6 ++-- 8 files changed, 28 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/trafikverket_camera/config_flow.py b/homeassistant/components/trafikverket_camera/config_flow.py index b8a14a5424e394..e1f8220c4ff5c3 100644 --- a/homeassistant/components/trafikverket_camera/config_flow.py +++ b/homeassistant/components/trafikverket_camera/config_flow.py @@ -10,7 +10,7 @@ NoCameraFound, UnknownError, ) -from pytrafikverket.trafikverket_camera import TrafikverketCamera +from pytrafikverket.trafikverket_camera import CameraInfo, TrafikverketCamera import voluptuous as vol from homeassistant import config_entries @@ -29,14 +29,17 @@ class TVCameraConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): entry: config_entries.ConfigEntry | None - async def validate_input(self, sensor_api: str, location: str) -> dict[str, str]: + async def validate_input( + self, sensor_api: str, location: str + ) -> tuple[dict[str, str], str | None]: """Validate input from user input.""" errors: dict[str, str] = {} + camera_info: CameraInfo | None = None web_session = async_get_clientsession(self.hass) camera_api = TrafikverketCamera(web_session, sensor_api) try: - await camera_api.async_get_camera(location) + camera_info = await camera_api.async_get_camera(location) except NoCameraFound: errors["location"] = "invalid_location" except MultipleCamerasFound: @@ -46,7 +49,8 @@ async def validate_input(self, sensor_api: str, location: str) -> dict[str, str] except UnknownError: errors["base"] = "cannot_connect" - return errors + camera_location = camera_info.location if camera_info else None + return (errors, camera_location) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Handle re-authentication with Trafikverket.""" @@ -58,13 +62,15 @@ async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: """Confirm re-authentication with Trafikverket.""" - errors = {} + errors: dict[str, str] = {} if user_input: api_key = user_input[CONF_API_KEY] assert self.entry is not None - errors = await self.validate_input(api_key, self.entry.data[CONF_LOCATION]) + errors, _ = await self.validate_input( + api_key, self.entry.data[CONF_LOCATION] + ) if not errors: self.hass.config_entries.async_update_entry( @@ -91,22 +97,23 @@ async def async_step_user( self, user_input: dict[str, str] | None = None ) -> FlowResult: """Handle the initial step.""" - errors = {} + errors: dict[str, str] = {} if user_input: api_key = user_input[CONF_API_KEY] location = user_input[CONF_LOCATION] - errors = await self.validate_input(api_key, location) + errors, camera_location = await self.validate_input(api_key, location) if not errors: - await self.async_set_unique_id(f"{DOMAIN}-{location}") + assert camera_location + await self.async_set_unique_id(f"{DOMAIN}-{camera_location}") self._abort_if_unique_id_configured() return self.async_create_entry( - title=user_input[CONF_LOCATION], + title=camera_location, data={ CONF_API_KEY: api_key, - CONF_LOCATION: location, + CONF_LOCATION: camera_location, }, ) diff --git a/homeassistant/components/trafikverket_camera/manifest.json b/homeassistant/components/trafikverket_camera/manifest.json index 440d7237171f92..d23631c6878289 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.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_ferry/manifest.json b/homeassistant/components/trafikverket_ferry/manifest.json index 47f1e62be00648..9d0b904290c100 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.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_train/manifest.json b/homeassistant/components/trafikverket_train/manifest.json index 47b4c21c867d0f..ab1f7feb3f7ef9 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.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/homeassistant/components/trafikverket_weatherstation/manifest.json b/homeassistant/components/trafikverket_weatherstation/manifest.json index 8c46afa597243d..138af544066934 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.5"] + "requirements": ["pytrafikverket==0.3.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 5341de900da8e5..f6929065d3c2e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2199,7 +2199,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index da11cbb5775062..c22770d9d2f05b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1619,7 +1619,7 @@ pytradfri[async]==9.0.1 # homeassistant.components.trafikverket_ferry # homeassistant.components.trafikverket_train # homeassistant.components.trafikverket_weatherstation -pytrafikverket==0.3.5 +pytrafikverket==0.3.6 # homeassistant.components.usb pyudev==0.23.2 diff --git a/tests/components/trafikverket_camera/test_config_flow.py b/tests/components/trafikverket_camera/test_config_flow.py index 38c49d5420871d..aa6122b7efede1 100644 --- a/tests/components/trafikverket_camera/test_config_flow.py +++ b/tests/components/trafikverket_camera/test_config_flow.py @@ -10,6 +10,7 @@ NoCameraFound, UnknownError, ) +from pytrafikverket.trafikverket_camera import CameraInfo from homeassistant import config_entries from homeassistant.components.trafikverket_camera.const import CONF_LOCATION, DOMAIN @@ -20,7 +21,7 @@ from tests.common import MockConfigEntry -async def test_form(hass: HomeAssistant) -> None: +async def test_form(hass: HomeAssistant, get_camera: CameraInfo) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( @@ -31,6 +32,7 @@ async def test_form(hass: HomeAssistant) -> None: with patch( "homeassistant.components.trafikverket_camera.config_flow.TrafikverketCamera.async_get_camera", + return_value=get_camera, ), patch( "homeassistant.components.trafikverket_camera.async_setup_entry", return_value=True, @@ -39,7 +41,7 @@ async def test_form(hass: HomeAssistant) -> None: result["flow_id"], { CONF_API_KEY: "1234567890", - CONF_LOCATION: "Test location", + CONF_LOCATION: "Test loc", }, ) await hass.async_block_till_done() From bb2cdbe7bca61171cb3163eaea18cbbc2e99a262 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 09:54:40 -0500 Subject: [PATCH 266/640] Change SSDP discovery scan interval to 10 minutes (#99975) * Change SSDP discovery scan interval to 10 minutes The first version used a scan interval of 1 minute which we increased to 2 minutes because it generated too much traffic. We kept it at 2 minutes because Sonos historicly needed to get SSDP discovery to stay alive. This is no longer the case as Sonos has multiple ways to keep from going unavailable: - mDNS support was added - We now listen for SSDP alive and good bye all the time - Each incoming packet from the device keeps it alive now - We probe when we think the device might be offline This means it should no longer be necessary to have such a frequent scan which is a drag on all devices on the network since its multicast * adjust tests --- homeassistant/components/ssdp/__init__.py | 2 +- tests/components/ssdp/test_init.py | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index 986eabf4e82808..aaffc5a157afa3 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -63,7 +63,7 @@ UPNP_SERVER = "server" UPNP_SERVER_MIN_PORT = 40000 UPNP_SERVER_MAX_PORT = 40100 -SCAN_INTERVAL = timedelta(minutes=2) +SCAN_INTERVAL = timedelta(minutes=10) IPV4_BROADCAST = IPv4Address("255.255.255.255") diff --git a/tests/components/ssdp/test_init.py b/tests/components/ssdp/test_init.py index ed5241a42ad26e..324136c011b222 100644 --- a/tests/components/ssdp/test_init.py +++ b/tests/components/ssdp/test_init.py @@ -1,5 +1,5 @@ """Test the SSDP integration.""" -from datetime import datetime, timedelta +from datetime import datetime from ipaddress import IPv4Address from unittest.mock import ANY, AsyncMock, patch @@ -447,7 +447,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -455,7 +455,7 @@ async def test_start_stop_scanner(mock_source_set, hass: HomeAssistant) -> None: hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_start.call_count == 1 assert ssdp_listener.async_search.call_count == 4 @@ -785,7 +785,7 @@ async def test_ipv4_does_additional_search_for_sonos( hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) await hass.async_block_till_done() - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=200)) + async_fire_time_changed(hass, dt_util.utcnow() + ssdp.SCAN_INTERVAL) await hass.async_block_till_done() assert ssdp_listener.async_search.call_count == 6 From fdddbd73633c6e58f9b6bbd63fc77aa8c3cbc968 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 9 Sep 2023 17:45:19 +0200 Subject: [PATCH 267/640] Bump pymodbus to v3.5.2 (#99988) --- homeassistant/components/modbus/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index bef85f1d20d5ee..b70055e5fbe6dc 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.1"] + "requirements": ["pymodbus==3.5.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index f6929065d3c2e8..89dbf774fe7567 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1852,7 +1852,7 @@ pymitv==1.4.3 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.1 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c22770d9d2f05b..94a48d0793ee01 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1374,7 +1374,7 @@ pymeteoclimatic==0.0.6 pymochad==0.2.0 # homeassistant.components.modbus -pymodbus==3.5.1 +pymodbus==3.5.2 # homeassistant.components.monoprice pymonoprice==0.4 From 9be16d9d42a05409c8fd4db6fcc5456fb9ee5312 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 17:49:54 +0200 Subject: [PATCH 268/640] Add config flow to WAQI (#98220) * Migrate WAQI to aiowaqi library * Migrate WAQI to aiowaqi library * Migrate WAQI to aiowaqi library * Add config flow to WAQI * Finish config flow * Add tests * Add tests * Fix ruff * Add issues on failing to import * Add issues on failing to import * Add issues on failing to import * Add importing issue * Finish coverage * Remove url from translation string * Fix feedback * Fix feedback --- CODEOWNERS | 3 +- homeassistant/components/waqi/__init__.py | 38 +++- homeassistant/components/waqi/config_flow.py | 135 +++++++++++++ homeassistant/components/waqi/const.py | 10 + homeassistant/components/waqi/coordinator.py | 36 ++++ homeassistant/components/waqi/manifest.json | 3 +- homeassistant/components/waqi/sensor.py | 179 ++++++++++-------- homeassistant/components/waqi/strings.json | 39 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/waqi/__init__.py | 1 + tests/components/waqi/conftest.py | 30 +++ .../waqi/fixtures/air_quality_sensor.json | 160 ++++++++++++++++ .../waqi/fixtures/search_result.json | 32 ++++ tests/components/waqi/test_config_flow.py | 108 +++++++++++ tests/components/waqi/test_sensor.py | 124 ++++++++++++ 17 files changed, 825 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/waqi/config_flow.py create mode 100644 homeassistant/components/waqi/const.py create mode 100644 homeassistant/components/waqi/coordinator.py create mode 100644 homeassistant/components/waqi/strings.json create mode 100644 tests/components/waqi/__init__.py create mode 100644 tests/components/waqi/conftest.py create mode 100644 tests/components/waqi/fixtures/air_quality_sensor.json create mode 100644 tests/components/waqi/fixtures/search_result.json create mode 100644 tests/components/waqi/test_config_flow.py create mode 100644 tests/components/waqi/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0cb1bef619189c..ba792b07183d53 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1391,7 +1391,8 @@ build.json @home-assistant/supervisor /tests/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline -/homeassistant/components/waqi/ @andrey-git +/homeassistant/components/waqi/ @joostlek +/tests/components/waqi/ @joostlek /homeassistant/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core /homeassistant/components/watson_tts/ @rutkai diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 5cacd9e5e1be2c..bc51a91364ce26 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1 +1,37 @@ -"""The waqi component.""" +"""The World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from aiowaqi import WAQIClient + +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 + +from .const import DOMAIN +from .coordinator import WAQIDataUpdateCoordinator + +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.""" + + client = WAQIClient(session=async_get_clientsession(hass)) + client.authenticate(entry.data[CONF_API_KEY]) + + waqi_coordinator = WAQIDataUpdateCoordinator(hass, client) + await waqi_coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_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/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 00000000000000..b5f3a18b223e06 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -0,0 +1,135 @@ +"""Config flow for World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from aiowaqi import ( + WAQIAirQuality, + WAQIAuthenticationError, + WAQIClient, + WAQIConnectionError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, + 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.typing import ConfigType + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER + +_LOGGER = logging.getLogger(__name__) + + +class WAQIConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for World Air Quality Index (WAQI).""" + + 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: + async with WAQIClient( + 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] + ) + ) + except WAQIAuthenticationError: + errors["base"] = "invalid_auth" + except WAQIConnectionError: + errors["base"] = "cannot_connect" + except Exception as exc: # pylint: disable=broad-except + _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, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=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, + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Handle importing from yaml.""" + await self.async_set_unique_id(str(import_config[CONF_STATION_NUMBER])) + try: + self._abort_if_unique_id_configured() + except AbortFlow as exc: + async_create_issue( + self.hass, + DOMAIN, + "deprecated_yaml_import_issue_already_configured", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="deprecated_yaml_import_issue_already_configured", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + raise exc + + async_create_issue( + self.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": "World Air Quality Index", + }, + ) + return self.async_create_entry( + title=import_config[CONF_NAME], + data={ + CONF_API_KEY: import_config[CONF_API_KEY], + CONF_STATION_NUMBER: import_config[CONF_STATION_NUMBER], + }, + ) diff --git a/homeassistant/components/waqi/const.py b/homeassistant/components/waqi/const.py new file mode 100644 index 00000000000000..2847a29b8add8d --- /dev/null +++ b/homeassistant/components/waqi/const.py @@ -0,0 +1,10 @@ +"""Constants for the World Air Quality Index (WAQI) integration.""" +import logging + +DOMAIN = "waqi" + +LOGGER = logging.getLogger(__package__) + +CONF_STATION_NUMBER = "station_number" + +ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"} diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py new file mode 100644 index 00000000000000..b7beef8fda9038 --- /dev/null +++ b/homeassistant/components/waqi/coordinator.py @@ -0,0 +1,36 @@ +"""Coordinator for the World Air Quality Index (WAQI) integration.""" +from __future__ import annotations + +from datetime import timedelta + +from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER + + +class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]): + """The WAQI Data Update Coordinator.""" + + config_entry: ConfigEntry + + def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None: + """Initialize the WAQI data coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + ) + self._client = client + + async def _async_update_data(self) -> WAQIAirQuality: + try: + return await self._client.get_by_station_number( + self.config_entry.data[CONF_STATION_NUMBER] + ) + except WAQIError as exc: + raise UpdateFailed from exc diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 2022558a5006b2..bf31fb570a8d59 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,7 +1,8 @@ { "domain": "waqi", "name": "World Air Quality Index (WAQI)", - "codeowners": ["@andrey-git"], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 51b9acb8e59a85..0ad295ca5af9a3 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,10 +1,9 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations -from datetime import timedelta import logging -from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult +from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,10 +11,13 @@ SensorEntity, 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, ) from homeassistant.core import HomeAssistant @@ -23,7 +25,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER +from .coordinator import WAQIDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,8 +50,6 @@ CONF_LOCATIONS = "locations" CONF_STATIONS = "stations" -SCAN_INTERVAL = timedelta(minutes=5) - TIMEOUT = 10 PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( @@ -70,102 +75,126 @@ async def async_setup_platform( client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT) client.authenticate(token) - dev = [] + station_count = 0 try: for location_name in locations: stations = await client.search(location_name) _LOGGER.debug("The following stations were returned: %s", stations) for station in stations: - waqi_sensor = WaqiSensor(client, station) + station_count = station_count + 1 if not station_filter or { - waqi_sensor.uid, - waqi_sensor.url, - waqi_sensor.station_name, + station.station_id, + station.station.external_url, + station.station.name, } & set(station_filter): - dev.append(waqi_sensor) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: station.station_id, + CONF_NAME: station.station.name, + CONF_API_KEY: config[CONF_TOKEN], + }, + ) + ) + except WAQIAuthenticationError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_invalid_auth", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_invalid_auth", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + _LOGGER.exception("Could not authenticate with WAQI") + raise PlatformNotReady from err except WAQIConnectionError as err: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_cannot_connect", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_cannot_connect", + translation_placeholders=ISSUE_PLACEHOLDER, + ) _LOGGER.exception("Failed to connect to WAQI servers") raise PlatformNotReady from err - async_add_entities(dev, True) + if station_count == 0: + async_create_issue( + hass, + DOMAIN, + "deprecated_yaml_import_issue_none_found", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml_import_issue_none_found", + translation_placeholders=ISSUE_PLACEHOLDER, + ) + + +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)]) -class WaqiSensor(SensorEntity): +class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT - _data: WAQIAirQuality | None = None - - def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None: + def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" - self._client = client - self.uid = search_result.station_id - self.url = search_result.station.external_url - self.station_name = search_result.station.name - - @property - def name(self): - """Return the name of the sensor.""" - if self.station_name: - return f"WAQI {self.station_name}" - return f"WAQI {self.url if self.url else self.uid}" + super().__init__(coordinator) + self._attr_name = f"WAQI {self.coordinator.data.city.name}" + self._attr_unique_id = str(coordinator.data.station_id) @property def native_value(self) -> int | None: """Return the state of the device.""" - assert self._data - return self._data.air_quality_index - - @property - def available(self): - """Return sensor availability.""" - return self._data is not None - - @property - def unique_id(self): - """Return unique ID.""" - return self.uid + return self.coordinator.data.air_quality_index @property def extra_state_attributes(self): """Return the state attributes of the last update.""" attrs = {} - - if self._data is not None: - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [attribution.name for attribution in self._data.attributions] - ) - - attrs[ATTR_TIME] = self._data.measured_at - attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant - - iaqi = self._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} - - async def async_update(self) -> None: - """Get the latest data and updates the states.""" - if self.uid: - result = await self._client.get_by_station_number(self.uid) - elif self.url: - result = await self._client.get_by_name(self.url) - else: - result = None - self._data = result + try: + attrs[ATTR_ATTRIBUTION] = " and ".join( + [ATTRIBUTION] + + [ + attribution.name + for attribution in self.coordinator.data.attributions + ] + ) + + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant + + 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} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json new file mode 100644 index 00000000000000..4ceb911de9e94a --- /dev/null +++ b/homeassistant/components/waqi/strings.json @@ -0,0 +1,39 @@ +{ + "config": { + "step": { + "user": { + "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%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "issues": { + "deprecated_yaml_import_issue_invalid_auth": { + "title": "The World Air Quality Index YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_cannot_connect": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to WAQI works and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually." + }, + "deprecated_yaml_import_issue_already_configured": { + "title": "The WAQI YAML configuration import failed", + "description": "Configuring World Air Quality Index using YAML is being removed but the measuring station was already imported when trying to import the YAML configuration.\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." + }, + "deprecated_yaml_import_issue_none_found": { + "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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6c992fd4b5e4b7..0f55df7cc99b90 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -516,6 +516,7 @@ "volvooncall", "vulcan", "wallbox", + "waqi", "watttime", "waze_travel_time", "webostv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 379dd11267224f..5eaf1b8d0a4d18 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6288,7 +6288,7 @@ "waqi": { "name": "World Air Quality Index (WAQI)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "waterfurnace": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94a48d0793ee01..9ea3661450ca08 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,6 +347,9 @@ aiovlc==0.1.0 # homeassistant.components.vodafone_station aiovodafone==0.1.0 +# homeassistant.components.waqi +aiowaqi==0.2.1 + # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py new file mode 100644 index 00000000000000..b6f36680ee368b --- /dev/null +++ b/tests/components/waqi/__init__.py @@ -0,0 +1 @@ +"""Tests for the World Air Quality Index (WAQI) integration.""" diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py new file mode 100644 index 00000000000000..176c1e27d8f78d --- /dev/null +++ b/tests/components/waqi/conftest.py @@ -0,0 +1,30 @@ +"""Common fixtures for the World Air Quality Index (WAQI) tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import CONF_API_KEY + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.waqi.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id="4584", + title="de Jongweg, Utrecht", + data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584}, + ) diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json new file mode 100644 index 00000000000000..49f1184822fe7b --- /dev/null +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -0,0 +1,160 @@ +{ + "aqi": 29, + "idx": 4584, + "attributions": [ + { + "url": "http://www.luchtmeetnet.nl/", + "name": "RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit", + "logo": "Netherland-RIVM.png" + }, + { + "url": "https://waqi.info/", + "name": "World Air Quality Index Project" + } + ], + "city": { + "geo": [52.105031, 5.124464], + "name": "de Jongweg, Utrecht", + "url": "https://aqicn.org/city/netherland/utrecht/de-jongweg", + "location": "" + }, + "dominentpol": "o3", + "iaqi": { + "h": { + "v": 80 + }, + "no2": { + "v": 2.3 + }, + "o3": { + "v": 29.4 + }, + "p": { + "v": 1008.8 + }, + "pm10": { + "v": 12 + }, + "pm25": { + "v": 17 + }, + "t": { + "v": 16 + }, + "w": { + "v": 1.4 + }, + "wg": { + "v": 2.4 + } + }, + "time": { + "s": "2023-08-07 17:00:00", + "tz": "+02:00", + "v": 1691427600, + "iso": "2023-08-07T17:00:00+02:00" + }, + "forecast": { + "daily": { + "o3": [ + { + "avg": 28, + "day": "2023-08-07", + "max": 34, + "min": 25 + }, + { + "avg": 22, + "day": "2023-08-08", + "max": 29, + "min": 19 + }, + { + "avg": 23, + "day": "2023-08-09", + "max": 35, + "min": 9 + }, + { + "avg": 18, + "day": "2023-08-10", + "max": 38, + "min": 3 + }, + { + "avg": 17, + "day": "2023-08-11", + "max": 17, + "min": 11 + } + ], + "pm10": [ + { + "avg": 8, + "day": "2023-08-07", + "max": 10, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-08", + "max": 12, + "min": 6 + }, + { + "avg": 9, + "day": "2023-08-09", + "max": 13, + "min": 6 + }, + { + "avg": 23, + "day": "2023-08-10", + "max": 33, + "min": 10 + }, + { + "avg": 27, + "day": "2023-08-11", + "max": 34, + "min": 27 + } + ], + "pm25": [ + { + "avg": 19, + "day": "2023-08-07", + "max": 29, + "min": 11 + }, + { + "avg": 25, + "day": "2023-08-08", + "max": 37, + "min": 19 + }, + { + "avg": 27, + "day": "2023-08-09", + "max": 45, + "min": 19 + }, + { + "avg": 64, + "day": "2023-08-10", + "max": 86, + "min": 33 + }, + { + "avg": 72, + "day": "2023-08-11", + "max": 89, + "min": 72 + } + ] + } + }, + "debug": { + "sync": "2023-08-08T01:29:52+09:00" + } +} diff --git a/tests/components/waqi/fixtures/search_result.json b/tests/components/waqi/fixtures/search_result.json new file mode 100644 index 00000000000000..65da5abc09a5a7 --- /dev/null +++ b/tests/components/waqi/fixtures/search_result.json @@ -0,0 +1,32 @@ +[ + { + "uid": 6332, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "Griftpark, Utrecht", + "geo": [52.101308, 5.128183], + "url": "netherland/utrecht/griftpark", + "country": "NL" + } + }, + { + "uid": 4584, + "aqi": "27", + "time": { + "tz": "+02:00", + "stime": "2023-08-08 15:00:00", + "vtime": 1691499600 + }, + "station": { + "name": "de Jongweg, Utrecht", + "geo": [52.105031, 5.124464], + "url": "netherland/utrecht/de-jongweg", + "country": "NL" + } + } +] diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py new file mode 100644 index 00000000000000..3901ffad550eea --- /dev/null +++ b/tests/components/waqi/test_config_flow.py @@ -0,0 +1,108 @@ +"""Test the World Air Quality Index (WAQI) config flow.""" +import json +from unittest.mock import AsyncMock, patch + +from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError +import pytest + +from homeassistant import config_entries +from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LOCATION, + CONF_LONGITUDE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +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: + """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_coordinates", + 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", + }, + ) + 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 + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (WAQIAuthenticationError(), "invalid_auth"), + (WAQIConnectionError(), "cannot_connect"), + (Exception(), "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + """Test we handle errors during configuration.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "aiowaqi.WAQIClient.authenticate", + ), patch( + "aiowaqi.WAQIClient.get_by_coordinates", + 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", + }, + ) + 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")) + ), + ): + 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", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py new file mode 100644 index 00000000000000..18f77028a29d18 --- /dev/null +++ b/tests/components/waqi/test_sensor.py @@ -0,0 +1,124 @@ +"""Test the World Air Quality Index (WAQI) sensor.""" +import json +from unittest.mock import patch + +from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult + +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 +from homeassistant.const import ( + CONF_API_KEY, + CONF_NAME, + CONF_PLATFORM, + CONF_TOKEN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + +LEGACY_CONFIG = { + Platform.SENSOR: [ + { + CONF_PLATFORM: DOMAIN, + CONF_TOKEN: "asd", + CONF_LOCATIONS: ["utrecht"], + CONF_STATIONS: [6332], + } + ] +} + + +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) + for search_result in search_result_json + ] + with patch( + "aiowaqi.WAQIClient.search", + return_value=search_results, + ), patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + 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 + + +async def test_legacy_migration_already_imported( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test migration from yaml to config flow after already imported.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_STATION_NUMBER: 4584, + CONF_NAME: "xyz", + CONF_API_KEY: "asd", + }, + ) + ) + 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 + + +async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + return_value=WAQIAirQuality.parse_obj( + json.loads(load_fixture("waqi/air_quality_sensor.json")) + ), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.waqi_de_jongweg_utrecht") + assert state.state == "29" + + +async def test_updating_failed( + hass: HomeAssistant, mock_config_entry: MockConfigEntry +) -> None: + """Test failed update.""" + mock_config_entry.add_to_hass(hass) + with patch( + "aiowaqi.WAQIClient.get_by_station_number", + side_effect=WAQIError(), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY From 71726130c31ff8863f3731f849ce4fdd65d6a9ba Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 12:12:14 -0400 Subject: [PATCH 269/640] Add binary sensors to Roborock (#99990) * init binary sensors commit * add binary sensors * fix test * Apply suggestions from code review Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/roborock/binary_sensor.py | 110 ++++++++++++++++++ homeassistant/components/roborock/const.py | 1 + .../components/roborock/strings.json | 11 ++ .../components/roborock/test_binary_sensor.py | 17 +++ 4 files changed, 139 insertions(+) create mode 100644 homeassistant/components/roborock/binary_sensor.py create mode 100644 tests/components/roborock/test_binary_sensor.py diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py new file mode 100644 index 00000000000000..d61c1ee14b9c7c --- /dev/null +++ b/homeassistant/components/roborock/binary_sensor.py @@ -0,0 +1,110 @@ +"""Support for Roborock sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from roborock.roborock_typing import DeviceProp + +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 +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify + +from .const import DOMAIN +from .coordinator import RoborockDataUpdateCoordinator +from .device import RoborockCoordinatedEntity + + +@dataclass +class RoborockBinarySensorDescriptionMixin: + """A class that describes binary sensor entities.""" + + value_fn: Callable[[DeviceProp], bool] + + +@dataclass +class RoborockBinarySensorDescription( + BinarySensorEntityDescription, RoborockBinarySensorDescriptionMixin +): + """A class that describes Roborock binary sensors.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + RoborockBinarySensorDescription( + key="dry_status", + translation_key="mop_drying_status", + icon="mdi:heat-wave", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.dry_status, + ), + RoborockBinarySensorDescription( + key="water_box_carriage_status", + translation_key="mop_attached", + icon="mdi:square-rounded", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_carriage_status, + ), + RoborockBinarySensorDescription( + key="water_box_status", + translation_key="water_box_attached", + icon="mdi:water", + device_class=BinarySensorDeviceClass.CONNECTIVITY, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_box_status, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Roborock vacuum binary sensors.""" + coordinators: dict[str, RoborockDataUpdateCoordinator] = hass.data[DOMAIN][ + config_entry.entry_id + ] + async_add_entities( + RoborockBinarySensorEntity( + f"{description.key}_{slugify(device_id)}", + coordinator, + description, + ) + for device_id, coordinator in coordinators.items() + for description in BINARY_SENSOR_DESCRIPTIONS + if description.value_fn(coordinator.roborock_device_info.props) is not None + ) + + +class RoborockBinarySensorEntity(RoborockCoordinatedEntity, BinarySensorEntity): + """Representation of a Roborock binary sensor.""" + + entity_description: RoborockBinarySensorDescription + + def __init__( + self, + unique_id: str, + coordinator: RoborockDataUpdateCoordinator, + description: RoborockBinarySensorDescription, + ) -> None: + """Initialize the entity.""" + super().__init__(unique_id, coordinator) + self.entity_description = description + + @property + def is_on(self) -> bool: + """Return the value reported by the sensor.""" + return bool( + self.entity_description.value_fn( + self.coordinator.roborock_device_info.props + ) + ) diff --git a/homeassistant/components/roborock/const.py b/homeassistant/components/roborock/const.py index 2fc59134d140f8..36078e53b3e9ef 100644 --- a/homeassistant/components/roborock/const.py +++ b/homeassistant/components/roborock/const.py @@ -13,4 +13,5 @@ Platform.SWITCH, Platform.TIME, Platform.NUMBER, + Platform.BINARY_SENSOR, ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 5ca2292f804186..269bbf04cf20ad 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -27,6 +27,17 @@ } }, "entity": { + "binary_sensor": { + "mop_attached": { + "name": "Mop attached" + }, + "mop_drying_status": { + "name": "Mop drying" + }, + "water_box_attached": { + "name": "Water box attached" + } + }, "number": { "volume": { "name": "Volume" diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py new file mode 100644 index 00000000000000..d4d415424bc45e --- /dev/null +++ b/tests/components/roborock/test_binary_sensor.py @@ -0,0 +1,17 @@ +"""Test Roborock Binary Sensor.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +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")) == 2 + 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 + == "on" + ) From 743ce463117dcf06cb00a285aa90c67dab72e6e1 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 9 Sep 2023 18:34:01 +0200 Subject: [PATCH 270/640] Deprecate CLOSE_COMM_ON_ERROR (#99946) --- homeassistant/components/modbus/modbus.py | 20 +++++++++++++++++++- homeassistant/components/modbus/strings.json | 6 ++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index fdb7be3d3cf84c..238df4466c432c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -34,6 +34,7 @@ from homeassistant.helpers.discovery import async_load_platform 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.reload import async_setup_reload_service from homeassistant.helpers.typing import ConfigType @@ -255,6 +256,24 @@ class ModbusHub: def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" + if CONF_CLOSE_COMM_ON_ERROR in client_config: + async_create_issue( # pragma: no cover + hass, + DOMAIN, + "deprecated_close_comm_config", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_close_comm_config", + translation_placeholders={ + "config_key": "close_comm_on_error", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`close_comm_on_error`: is deprecated and will be remove in version 2024.4" + ) # generic configuration self._client: ModbusBaseClient | None = None self._async_cancel_listener: Callable[[], None] | None = None @@ -274,7 +293,6 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: self._pb_params = { "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], - "reset_socket": client_config[CONF_CLOSE_COMM_ON_ERROR], "retries": client_config[CONF_RETRIES], "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], } diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 61694074d7920f..780757a3eeb8b3 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -68,5 +68,11 @@ } } } + }, + "issues": { + "deprecated_close_comm_config": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + } } } From 868fdd81da2687bd382aec642121539026d7929c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 18:48:09 +0200 Subject: [PATCH 271/640] Add entity translations to withings (#99194) * Add entity translations to Withings * Add entity translations to Withings --- .../components/withings/binary_sensor.py | 2 +- homeassistant/components/withings/common.py | 4 +- homeassistant/components/withings/sensor.py | 63 ++++++------ .../components/withings/strings.json | 99 +++++++++++++++++++ 4 files changed, 132 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index 6b072030bda383..e1351d7c01951e 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -36,7 +36,7 @@ class WithingsBinarySensorEntityDescription( key=Measurement.IN_BED.value, measurement=Measurement.IN_BED, measure_type=NotifyAppli.BED_IN, - name="In bed", + translation_key="in_bed", icon="mdi:bed", update_type=UpdateType.WEBHOOK, device_class=BinarySensorDeviceClass.OCCUPANCY, diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 17e3c551bcce55..76124cfff91131 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -548,6 +548,7 @@ class BaseWithingsSensor(Entity): _attr_should_poll = False entity_description: WithingsEntityDescription + _attr_has_entity_name = True def __init__( self, data_manager: DataManager, description: WithingsEntityDescription @@ -555,9 +556,6 @@ def __init__( """Initialize the Withings sensor.""" self._data_manager = data_manager self.entity_description = description - self._attr_name = ( - f"Withings {description.measurement.value} {data_manager.profile}" - ) self._attr_unique_id = get_attribute_unique_id( description, data_manager.user_id ) diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index c2cdd89a17f714..4f98daacc42106 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -51,7 +51,6 @@ class WithingsSensorEntityDescription( key=Measurement.WEIGHT_KG.value, measurement=Measurement.WEIGHT_KG, measure_type=MeasureType.WEIGHT, - name="Weight", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -61,7 +60,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_MASS_KG.value, measurement=Measurement.FAT_MASS_KG, measure_type=MeasureType.FAT_MASS_WEIGHT, - name="Fat Mass", + translation_key="fat_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -71,7 +70,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_FREE_MASS_KG.value, measurement=Measurement.FAT_FREE_MASS_KG, measure_type=MeasureType.FAT_FREE_MASS, - name="Fat Free Mass", + translation_key="fat_free_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -81,7 +80,7 @@ class WithingsSensorEntityDescription( key=Measurement.MUSCLE_MASS_KG.value, measurement=Measurement.MUSCLE_MASS_KG, measure_type=MeasureType.MUSCLE_MASS, - name="Muscle Mass", + translation_key="muscle_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -91,7 +90,7 @@ class WithingsSensorEntityDescription( key=Measurement.BONE_MASS_KG.value, measurement=Measurement.BONE_MASS_KG, measure_type=MeasureType.BONE_MASS, - name="Bone Mass", + translation_key="bone_mass", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, state_class=SensorStateClass.MEASUREMENT, @@ -101,7 +100,7 @@ class WithingsSensorEntityDescription( key=Measurement.HEIGHT_M.value, measurement=Measurement.HEIGHT_M, measure_type=MeasureType.HEIGHT, - name="Height", + translation_key="height", native_unit_of_measurement=UnitOfLength.METERS, device_class=SensorDeviceClass.DISTANCE, state_class=SensorStateClass.MEASUREMENT, @@ -112,7 +111,6 @@ class WithingsSensorEntityDescription( key=Measurement.TEMP_C.value, measurement=Measurement.TEMP_C, measure_type=MeasureType.TEMPERATURE, - name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -122,7 +120,7 @@ class WithingsSensorEntityDescription( key=Measurement.BODY_TEMP_C.value, measurement=Measurement.BODY_TEMP_C, measure_type=MeasureType.BODY_TEMPERATURE, - name="Body Temperature", + translation_key="body_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -132,7 +130,7 @@ class WithingsSensorEntityDescription( key=Measurement.SKIN_TEMP_C.value, measurement=Measurement.SKIN_TEMP_C, measure_type=MeasureType.SKIN_TEMPERATURE, - name="Skin Temperature", + translation_key="skin_temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, state_class=SensorStateClass.MEASUREMENT, @@ -142,7 +140,7 @@ class WithingsSensorEntityDescription( key=Measurement.FAT_RATIO_PCT.value, measurement=Measurement.FAT_RATIO_PCT, measure_type=MeasureType.FAT_RATIO, - name="Fat Ratio", + translation_key="fat_ratio", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -151,7 +149,7 @@ class WithingsSensorEntityDescription( key=Measurement.DIASTOLIC_MMHG.value, measurement=Measurement.DIASTOLIC_MMHG, measure_type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, - name="Diastolic Blood Pressure", + translation_key="diastolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -160,7 +158,7 @@ class WithingsSensorEntityDescription( key=Measurement.SYSTOLIC_MMGH.value, measurement=Measurement.SYSTOLIC_MMGH, measure_type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, - name="Systolic Blood Pressure", + translation_key="systolic_blood_pressure", native_unit_of_measurement=UOM_MMHG, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -169,7 +167,7 @@ class WithingsSensorEntityDescription( key=Measurement.HEART_PULSE_BPM.value, measurement=Measurement.HEART_PULSE_BPM, measure_type=MeasureType.HEART_RATE, - name="Heart Pulse", + translation_key="heart_pulse", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -179,7 +177,7 @@ class WithingsSensorEntityDescription( key=Measurement.SPO2_PCT.value, measurement=Measurement.SPO2_PCT, measure_type=MeasureType.SP02, - name="SP02", + translation_key="spo2", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, update_type=UpdateType.POLL, @@ -188,7 +186,7 @@ class WithingsSensorEntityDescription( key=Measurement.HYDRATION.value, measurement=Measurement.HYDRATION, measure_type=MeasureType.HYDRATION, - name="Hydration", + translation_key="hydration", native_unit_of_measurement=UnitOfMass.KILOGRAMS, device_class=SensorDeviceClass.WEIGHT, icon="mdi:water", @@ -200,7 +198,7 @@ class WithingsSensorEntityDescription( key=Measurement.PWV.value, measurement=Measurement.PWV, measure_type=MeasureType.PULSE_WAVE_VELOCITY, - name="Pulse Wave Velocity", + translation_key="pulse_wave_velocity", native_unit_of_measurement=UnitOfSpeed.METERS_PER_SECOND, device_class=SensorDeviceClass.SPEED, state_class=SensorStateClass.MEASUREMENT, @@ -210,7 +208,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY.value, measurement=Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, measure_type=GetSleepSummaryField.BREATHING_DISTURBANCES_INTENSITY, - name="Breathing disturbances intensity", + translation_key="breathing_disturbances_intensity", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -219,7 +217,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_DEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_DEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DEEP_SLEEP_DURATION, - name="Deep sleep", + translation_key="deep_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -231,7 +229,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_SLEEP, - name="Time to sleep", + translation_key="time_to_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -243,7 +241,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.DURATION_TO_WAKEUP, - name="Time to wakeup", + translation_key="time_to_wakeup", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, @@ -255,7 +253,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_AVERAGE.value, measurement=Measurement.SLEEP_HEART_RATE_AVERAGE, measure_type=GetSleepSummaryField.HR_AVERAGE, - name="Average heart rate", + translation_key="average_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -266,6 +264,7 @@ class WithingsSensorEntityDescription( 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", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", @@ -277,7 +276,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_HEART_RATE_MIN.value, measurement=Measurement.SLEEP_HEART_RATE_MIN, measure_type=GetSleepSummaryField.HR_MIN, - name="Minimum heart rate", + translation_key="maximum_heart_rate", native_unit_of_measurement=UOM_BEATS_PER_MINUTE, icon="mdi:heart-pulse", state_class=SensorStateClass.MEASUREMENT, @@ -288,7 +287,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_LIGHT_DURATION_SECONDS.value, measurement=Measurement.SLEEP_LIGHT_DURATION_SECONDS, measure_type=GetSleepSummaryField.LIGHT_SLEEP_DURATION, - name="Light sleep", + translation_key="light_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -300,7 +299,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_REM_DURATION_SECONDS.value, measurement=Measurement.SLEEP_REM_DURATION_SECONDS, measure_type=GetSleepSummaryField.REM_SLEEP_DURATION, - name="REM sleep", + translation_key="rem_sleep", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep", device_class=SensorDeviceClass.DURATION, @@ -312,7 +311,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, measure_type=GetSleepSummaryField.RR_AVERAGE, - name="Average respiratory rate", + translation_key="average_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -322,7 +321,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MAX.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MAX, measure_type=GetSleepSummaryField.RR_MAX, - name="Maximum respiratory rate", + translation_key="maximum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -332,7 +331,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_RESPIRATORY_RATE_MIN.value, measurement=Measurement.SLEEP_RESPIRATORY_RATE_MIN, measure_type=GetSleepSummaryField.RR_MIN, - name="Minimum respiratory rate", + translation_key="minimum_respiratory_rate", native_unit_of_measurement=UOM_BREATHS_PER_MINUTE, state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, @@ -342,7 +341,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SCORE.value, measurement=Measurement.SLEEP_SCORE, measure_type=GetSleepSummaryField.SLEEP_SCORE, - name="Sleep score", + translation_key="sleep_score", native_unit_of_measurement=SCORE_POINTS, icon="mdi:medal", state_class=SensorStateClass.MEASUREMENT, @@ -353,7 +352,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING.value, measurement=Measurement.SLEEP_SNORING, measure_type=GetSleepSummaryField.SNORING, - name="Snoring", + translation_key="snoring", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -362,7 +361,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_SNORING_EPISODE_COUNT.value, measurement=Measurement.SLEEP_SNORING_EPISODE_COUNT, measure_type=GetSleepSummaryField.SNORING_EPISODE_COUNT, - name="Snoring episode count", + translation_key="snoring_episode_count", state_class=SensorStateClass.MEASUREMENT, entity_registry_enabled_default=False, update_type=UpdateType.POLL, @@ -371,7 +370,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_COUNT.value, measurement=Measurement.SLEEP_WAKEUP_COUNT, measure_type=GetSleepSummaryField.WAKEUP_COUNT, - name="Wakeup count", + translation_key="wakeup_count", native_unit_of_measurement=UOM_FREQUENCY, icon="mdi:sleep-off", state_class=SensorStateClass.MEASUREMENT, @@ -382,7 +381,7 @@ class WithingsSensorEntityDescription( key=Measurement.SLEEP_WAKEUP_DURATION_SECONDS.value, measurement=Measurement.SLEEP_WAKEUP_DURATION_SECONDS, measure_type=GetSleepSummaryField.WAKEUP_DURATION, - name="Wakeup time", + translation_key="wakeup_time", native_unit_of_measurement=UnitOfTime.SECONDS, icon="mdi:sleep-off", device_class=SensorDeviceClass.DURATION, diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 8f8a32c95e7e2b..424a0edadce184 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -27,5 +27,104 @@ "create_entry": { "default": "Successfully authenticated with Withings." } + }, + "entity": { + "binary_sensor": { + "in_bed": { + "name": "In bed" + } + }, + "sensor": { + "fat_mass": { + "name": "Fat mass" + }, + "fat_free_mass": { + "name": "Fat free mass" + }, + "muscle_mass": { + "name": "Muscle mass" + }, + "bone_mass": { + "name": "Bone mass" + }, + "height": { + "name": "Height" + }, + "body_temperature": { + "name": "Body temperature" + }, + "skin_temperature": { + "name": "Skin temperature" + }, + "fat_ratio": { + "name": "Fat ratio" + }, + "diastolic_blood_pressure": { + "name": "Diastolic blood pressure" + }, + "systolic_blood_pressure": { + "name": "Systolic blood pressure" + }, + "heart_pulse": { + "name": "Heart pulse" + }, + "spo2": { + "name": "SpO2" + }, + "hydration": { + "name": "Hydration" + }, + "pulse_wave_velocity": { + "name": "Pulse wave velocity" + }, + "breathing_disturbances_intensity": { + "name": "Breathing disturbances intensity" + }, + "deep_sleep": { + "name": "Deep sleep" + }, + "time_to_sleep": { + "name": "Time to sleep" + }, + "time_to_wakeup": { + "name": "Time to wakeup" + }, + "average_heart_rate": { + "name": "Average heart rate" + }, + "maximum_heart_rate": { + "name": "Maximum heart rate" + }, + "light_sleep": { + "name": "Light sleep" + }, + "rem_sleep": { + "name": "REM sleep" + }, + "average_respiratory_rate": { + "name": "Average respiratory rate" + }, + "maximum_respiratory_rate": { + "name": "Maximum respiratory rate" + }, + "minimum_respiratory_rate": { + "name": "Minimum respiratory rate" + }, + "sleep_score": { + "name": "Sleep score" + }, + "snoring": { + "name": "Snoring" + }, + "snoring_episode_count": { + "name": "Snoring episode count" + }, + "wakeup_count": { + "name": "Wakeup count" + }, + "wakeup_time": { + "name": "Wakeup time" + } + } } } From a62ffeaa9983f5ce67242328ea8a3b4c8c054e5f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 19:11:28 +0200 Subject: [PATCH 272/640] Decouple Withings sensor tests from yaml (#99691) * Decouple Withings sensor tests from yaml * Fix feedback * Add pytest fixture * Update tests/components/withings/test_sensor.py Co-authored-by: G Johansson * Update snapshots * Update snapshots --------- Co-authored-by: G Johansson --- tests/components/withings/__init__.py | 84 ++ tests/components/withings/conftest.py | 345 ++++- .../withings/snapshots/test_sensor.ambr | 1254 +++++++++++++++++ tests/components/withings/test_sensor.py | 355 +---- 4 files changed, 1727 insertions(+), 311 deletions(-) create mode 100644 tests/components/withings/snapshots/test_sensor.ambr diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index c1caac222a5d93..e148c1a2c847e0 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1 +1,85 @@ """Tests for the withings component.""" +from collections.abc import Iterable +from typing import Any, Optional +from urllib.parse import urlparse + +import arrow +from withings_api import DateType +from withings_api.common import ( + GetSleepSummaryField, + MeasureGetMeasGroupCategory, + MeasureGetMeasResponse, + MeasureType, + SleepGetSummaryResponse, + UserGetDeviceResponse, +) + +from homeassistant.components.webhook import async_generate_url +from homeassistant.core import HomeAssistant + +from .common import ProfileConfig, WebhookResponse + + +async def call_webhook( + hass: HomeAssistant, webhook_id: str, data: dict[str, Any], client +) -> WebhookResponse: + """Call the webhook.""" + webhook_url = async_generate_url(hass, webhook_id) + + resp = await client.post( + urlparse(webhook_url).path, + data=data, + ) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data: dict[str, Any] = await resp.json() + resp.close() + + return WebhookResponse(message=data["message"], message_code=data["code"]) + + +class MockWithings: + """Mock object for Withings.""" + + def __init__(self, user_profile: ProfileConfig): + """Initialize mock.""" + self.api_response_user_get_device = user_profile.api_response_user_get_device + self.api_response_measure_get_meas = user_profile.api_response_measure_get_meas + self.api_response_sleep_get_summary = ( + user_profile.api_response_sleep_get_summary + ) + + def user_get_device(self) -> UserGetDeviceResponse: + """Get devices.""" + if isinstance(self.api_response_user_get_device, Exception): + raise self.api_response_user_get_device + return self.api_response_user_get_device + + def measure_get_meas( + self, + meastype: MeasureType | None = None, + category: MeasureGetMeasGroupCategory | None = None, + startdate: DateType | None = None, + enddate: DateType | None = None, + offset: int | None = None, + lastupdate: DateType | None = None, + ) -> MeasureGetMeasResponse: + """Get measurements.""" + if isinstance(self.api_response_measure_get_meas, Exception): + raise self.api_response_measure_get_meas + return self.api_response_measure_get_meas + + def sleep_get_summary( + self, + data_fields: Iterable[GetSleepSummaryField], + startdateymd: Optional[DateType] = arrow.utcnow(), + enddateymd: Optional[DateType] = arrow.utcnow(), + offset: Optional[int] = None, + lastupdate: Optional[DateType] = arrow.utcnow(), + ) -> SleepGetSummaryResponse: + """Get sleep.""" + if isinstance(self.api_response_sleep_get_summary, Exception): + raise self.api_response_sleep_get_summary + return self.api_response_sleep_get_summary diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 887a9b8179b238..510fc980dc72f5 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,15 +1,276 @@ """Fixtures for tests.""" - +from collections.abc import Awaitable, Callable, Coroutine +import time +from typing import Any from unittest.mock import patch +import arrow import pytest +from withings_api.common import ( + GetSleepSummaryData, + GetSleepSummarySerie, + MeasureGetMeasGroup, + MeasureGetMeasGroupAttrib, + MeasureGetMeasGroupCategory, + MeasureGetMeasMeasure, + MeasureGetMeasResponse, + MeasureType, + SleepGetSummaryResponse, + SleepModel, +) +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.withings.const import DOMAIN +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util -from .common import ComponentFactory +from . import MockWithings +from .common import ComponentFactory, new_profile_config +from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker +ComponentSetup = Callable[[], Awaitable[MockWithings]] + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" +SCOPES = [ + "user.info", + "user.metrics", + "user.activity", + "user.sleepevents", +] +TITLE = "henk" +WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + +PERSON0 = new_profile_config( + profile="12345", + user_id=12345, + api_response_measure_get_meas=MeasureGetMeasResponse( + measuregrps=( + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow().shift(hours=-1), + date=arrow.utcnow().shift(hours=-1), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=60 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=50 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=60 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=95 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 + ), + ), + ), + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow().shift(hours=-2), + date=arrow.utcnow().shift(hours=-2), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=61 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=51 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=61 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=96 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 + ), + ), + ), + MeasureGetMeasGroup( + attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, + category=MeasureGetMeasGroupCategory.REAL, + created=arrow.utcnow(), + date=arrow.utcnow(), + deviceid="DEV_ID", + grpid=1, + measures=( + MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), + MeasureGetMeasMeasure( + type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_FREE_MASS, unit=0, value=40 + ), + MeasureGetMeasMeasure( + type=MeasureType.MUSCLE_MASS, unit=0, value=51 + ), + MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), + MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), + MeasureGetMeasMeasure( + type=MeasureType.TEMPERATURE, unit=0, value=41 + ), + MeasureGetMeasMeasure( + type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 + ), + MeasureGetMeasMeasure( + type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 + ), + MeasureGetMeasMeasure( + type=MeasureType.FAT_RATIO, unit=-3, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 + ), + MeasureGetMeasMeasure( + type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 + ), + MeasureGetMeasMeasure( + type=MeasureType.HEART_RATE, unit=0, value=61 + ), + MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), + MeasureGetMeasMeasure( + type=MeasureType.HYDRATION, unit=-2, value=96 + ), + MeasureGetMeasMeasure( + type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 + ), + ), + ), + ), + more=False, + timezone=dt_util.UTC, + updatetime=arrow.get("2019-08-01"), + offset=0, + ), + api_response_sleep_get_summary=SleepGetSummaryResponse( + more=False, + offset=0, + series=( + GetSleepSummarySerie( + timezone=dt_util.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + 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, + ), + ), + GetSleepSummarySerie( + timezone=dt_util.UTC, + model=SleepModel.SLEEP_MONITOR, + startdate=arrow.get("2019-02-01"), + enddate=arrow.get("2019-02-01"), + date=arrow.get("2019-02-01"), + modified=arrow.get(12345), + data=GetSleepSummaryData( + 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, + ), + ), + ), + ), +) + @pytest.fixture def component_factory( @@ -25,3 +286,83 @@ def component_factory( yield ComponentFactory( hass, api_class_mock, hass_client_no_auth, aioclient_mock ) + + +@pytest.fixture(name="scopes") +def mock_scopes() -> list[str]: + """Fixture to set the scopes present in the OAuth token.""" + return SCOPES + + +@pytest.fixture(autouse=True) +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), + DOMAIN, + ) + + +@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="config_entry") +def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: + """Create Withings entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id="12345", + data={ + "auth_implementation": DOMAIN, + "token": { + "status": 0, + "userid": "12345", + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_at": expires_at, + "scope": ",".join(scopes), + }, + "profile": TITLE, + "use_webhook": True, + "webhook_id": WEBHOOK_ID, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, MockWithings]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) + + async def func() -> MockWithings: + mock = MockWithings(PERSON0) + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return mock + + return func diff --git a/tests/components/withings/snapshots/test_sensor.ambr b/tests/components/withings/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..6aa9e5b3784e66 --- /dev/null +++ b/tests/components/withings/snapshots/test_sensor.ambr @@ -0,0 +1,1254 @@ +# 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.0', + }) +# --- +# 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.0', + }) +# --- +# 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.0', + }) +# --- +# 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.0', + }) +# --- +# 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.0', + }) +# --- +# 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.0', + }) +# --- +# name: test_all_entities.16 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Breathing disturbances intensity', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_breathing_disturbances_intensity', + 'last_changed': , + 'last_updated': , + 'state': '160.0', + }) +# --- +# name: test_all_entities.17 + 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': '322', + }) +# --- +# name: test_all_entities.18 + 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': '162.0', + }) +# --- +# name: test_all_entities.19 + 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': '163.0', + }) +# --- +# 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.0', + }) +# --- +# name: test_all_entities.20 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average heart rate', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_average_heart_rate', + 'last_changed': , + 'last_updated': , + 'state': '164.0', + }) +# --- +# name: test_all_entities.21 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Fat mass', + 'icon': 'mdi:heart-pulse', + 'state_class': , + 'unit_of_measurement': 'bpm', + }), + 'context': , + 'entity_id': 'sensor.henk_fat_mass_2', + 'last_changed': , + 'last_updated': , + 'state': '165.0', + }) +# --- +# name: test_all_entities.22 + 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': '166.0', + }) +# --- +# name: test_all_entities.23 + 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': '334', + }) +# --- +# name: test_all_entities.24 + 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': '336', + }) +# --- +# name: test_all_entities.25 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Average respiratory rate', + 'state_class': , + 'unit_of_measurement': 'br/min', + }), + 'context': , + 'entity_id': 'sensor.henk_average_respiratory_rate', + 'last_changed': , + 'last_updated': , + 'state': '169.0', + }) +# --- +# 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': '170.0', + }) +# --- +# 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': '171.0', + }) +# --- +# 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': '222', + }) +# --- +# 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': '173.0', + }) +# --- +# name: test_all_entities.3 + 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.0', + }) +# --- +# name: test_all_entities.30 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'henk Snoring episode count', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.henk_snoring_episode_count', + 'last_changed': , + 'last_updated': , + 'state': '348', + }) +# --- +# name: test_all_entities.31 + 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': '350', + }) +# --- +# name: test_all_entities.32 + 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': '176.0', + }) +# --- +# 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({ + 'device_class': 'weight', + 'friendly_name': 'henk Bone mass', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_bone_mass', + 'last_changed': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# 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({ + 'device_class': 'distance', + 'friendly_name': 'henk Height', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_height', + 'last_changed': , + 'last_updated': , + 'state': '2.0', + }) +# --- +# 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({ + 'device_class': 'temperature', + 'friendly_name': 'henk Temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# 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({ + 'device_class': 'temperature', + 'friendly_name': 'henk Body temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_body_temperature', + 'last_changed': , + 'last_updated': , + 'state': '40.0', + }) +# --- +# name: test_all_entities.8 + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'henk Skin temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.henk_skin_temperature', + 'last_changed': , + 'last_updated': , + 'state': '20.0', + }) +# --- +# name: test_all_entities.9 + 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', + }) +# --- diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6c4bb867f75424..07fcb8fedaa4af 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -2,20 +2,9 @@ from typing import Any from unittest.mock import patch -import arrow -from withings_api.common import ( - GetSleepSummaryData, - GetSleepSummarySerie, - MeasureGetMeasGroup, - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureGetMeasMeasure, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - SleepGetSummaryResponse, - SleepModel, -) +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.common import WithingsEntityDescription @@ -24,236 +13,17 @@ from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from homeassistant.util import dt as dt_util -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import MockWithings, call_webhook +from .common import async_get_entity_id +from .conftest import PERSON0, WEBHOOK_ID, ComponentSetup + +from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { attr.measurement: attr for attr in SENSORS } -PERSON0 = new_profile_config( - "person0", - 0, - api_response_measure_get_meas=MeasureGetMeasResponse( - measuregrps=( - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-1), - date=arrow.utcnow().shift(hours=-1), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=60 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=50 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=60 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=95 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-2), - date=arrow.utcnow().shift(hours=-2), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=61 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow(), - date=arrow.utcnow(), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 - ), - ), - ), - ), - more=False, - timezone=dt_util.UTC, - updatetime=arrow.get("2019-08-01"), - offset=0, - ), - api_response_sleep_get_summary=SleepGetSummaryResponse( - more=False, - offset=0, - series=( - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - 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, - ), - ), - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - 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, - ), - ), - ), - ), -) EXPECTED_DATA = ( (PERSON0, Measurement.WEIGHT_KG, 70.0), @@ -304,79 +74,22 @@ def async_assert_state_equals( ) +@pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + setup_integration: ComponentSetup, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" + await setup_integration() entity_registry: EntityRegistry = er.async_get(hass) - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - - # Assert entities should exist. - for attribute in SENSORS: - entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - assert entity_id - assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) - assert resp.message_code == 0 - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) - assert resp.message_code == 0 - - for person, measurement, expected in EXPECTED_DATA: - attribute = WITHINGS_MEASUREMENTS_MAP[measurement] - entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN - ) - state_obj = hass.states.get(entity_id) - - if attribute.entity_registry_enabled_default: - async_assert_state_equals(entity_id, state_obj, expected, attribute) - else: - assert state_obj is None - - # Unload - await component_factory.unload(PERSON0) - - -async def test_all_entities( - hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, -) -> None: - """Test all entities.""" - entity_registry: EntityRegistry = er.async_get(hass) - + mock = MockWithings(PERSON0) with patch( - "homeassistant.components.withings.sensor.BaseWithingsSensor.entity_registry_enabled_default" - ) as enabled_by_default_mock: - enabled_by_default_mock.return_value = True - - await component_factory.configure_component(profile_configs=(PERSON0,)) - - # Assert entities should not exist yet. - for attribute in SENSORS: - assert not await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(PERSON0.user_id) - + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + client = await hass_client_no_auth() # Assert entities should exist. for attribute in SENSORS: entity_id = await async_get_entity_id( @@ -384,11 +97,21 @@ async def test_all_entities( ) assert entity_id assert entity_registry.async_is_registered(entity_id) - - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.SLEEP) + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": PERSON0.user_id, "appli": NotifyAppli.SLEEP}, + client, + ) + assert resp.message_code == 0 + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": PERSON0.user_id, "appli": NotifyAppli.WEIGHT}, + client, + ) assert resp.message_code == 0 - resp = await component_factory.call_webhook(PERSON0.user_id, NotifyAppli.WEIGHT) assert resp.message_code == 0 for person, measurement, expected in EXPECTED_DATA: @@ -400,5 +123,19 @@ async def test_all_entities( async_assert_state_equals(entity_id, state_obj, expected, attribute) - # Unload - await component_factory.unload(PERSON0) + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, setup_integration: ComponentSetup, snapshot: SnapshotAssertion +) -> None: + """Test all entities.""" + await setup_integration() + + mock = MockWithings(PERSON0) + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, 12345, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot From 862506af61e8c064f0859683d535cb9c940069fa Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Sat, 9 Sep 2023 19:16:27 +0200 Subject: [PATCH 273/640] Bump pywaze to 0.4.0 (#99995) bump pywaze from 0.3.0 to 0.4.0 --- 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 3f1f8c6d67ba22..c72d9b1dbad63f 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.3.0"] + "requirements": ["pywaze==0.4.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 89dbf774fe7567..b617b773adf91c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2232,7 +2232,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.4.0 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9ea3661450ca08..4427dd4c1a4166 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1646,7 +1646,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.3.0 +pywaze==0.4.0 # homeassistant.components.html5 pywebpush==1.9.2 From aff49cb67abc376d18a58347f13542bfbad81b6c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 12:23:15 -0500 Subject: [PATCH 274/640] Bump bluetooth-auto-recovery to 1.2.3 (#99979) fixes #99977 --- 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 d6753adf3c4cd1..e5df324ec02df2 100644 --- a/homeassistant/components/bluetooth/manifest.json +++ b/homeassistant/components/bluetooth/manifest.json @@ -17,7 +17,7 @@ "bleak==0.21.1", "bleak-retry-connector==3.1.3", "bluetooth-adapters==0.16.1", - "bluetooth-auto-recovery==1.2.2", + "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", "dbus-fast==2.0.1" ] diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 3c6be4df133b51..0857591e120d16 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -11,7 +11,7 @@ bcrypt==4.0.1 bleak-retry-connector==3.1.3 bleak==0.21.1 bluetooth-adapters==0.16.1 -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 diff --git a/requirements_all.txt b/requirements_all.txt index b617b773adf91c..6c5e57e8f830a4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -544,7 +544,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4427dd4c1a4166..9596bb81d3f4a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -458,7 +458,7 @@ bluemaestro-ble==0.2.3 bluetooth-adapters==0.16.1 # homeassistant.components.bluetooth -bluetooth-auto-recovery==1.2.2 +bluetooth-auto-recovery==1.2.3 # homeassistant.components.bluetooth # homeassistant.components.esphome From 23f4ccd4f11ddfad3874b98e14cdac15f18761e8 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sat, 9 Sep 2023 22:32:13 +0200 Subject: [PATCH 275/640] Fix late review findings in Minecraft Server (#99865) --- homeassistant/components/minecraft_server/__init__.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index a13196dffc6e2e..ee8bdbe2a3f299 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -77,9 +77,8 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # 1 --> 2: Use config entry ID as base for unique IDs. if config_entry.version == 1: - assert config_entry.unique_id - assert config_entry.entry_id old_unique_id = config_entry.unique_id + assert old_unique_id config_entry_id = config_entry.entry_id # Migrate config entry. @@ -94,7 +93,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> # Migrate entities. await er.async_migrate_entries(hass, config_entry_id, _migrate_entity_unique_id) - _LOGGER.info("Migration to version %s successful", config_entry.version) + _LOGGER.debug("Migration to version %s successful", config_entry.version) return True @@ -108,7 +107,6 @@ async def _async_migrate_device_identifiers( for device_entry in dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ): - assert device_entry for identifier in device_entry.identifiers: if identifier[1] == old_unique_id: # Device found in registry. Update identifiers. @@ -138,7 +136,6 @@ async def _async_migrate_device_identifiers( @callback def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: """Migrate the unique ID of an entity to the new format.""" - assert entity_entry # Different variants of unique IDs are available in version 1: # 1) SRV record: '-srv-' From eeaca8ae3c53ad2787b7336f8653b3a9454495ca Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 23:18:41 +0200 Subject: [PATCH 276/640] Use shorthand attributes in Vicare (#99915) --- .../components/vicare/binary_sensor.py | 22 ++--- homeassistant/components/vicare/button.py | 18 ++-- homeassistant/components/vicare/climate.py | 97 +++++------------- homeassistant/components/vicare/sensor.py | 12 +-- .../components/vicare/water_heater.py | 99 ++++--------------- 5 files changed, 60 insertions(+), 188 deletions(-) diff --git a/homeassistant/components/vicare/binary_sensor.py b/homeassistant/components/vicare/binary_sensor.py index 89e8bec42d105c..5aa76dc99629df 100644 --- a/homeassistant/components/vicare/binary_sensor.py +++ b/homeassistant/components/vicare/binary_sensor.py @@ -196,23 +196,18 @@ def __init__( self._api = api self.entity_description = description self._device_config = device_config - self._state = None - - @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(), + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device_config.getConfig().serial)}, + name=device_config.getModel(), manufacturer="Viessmann", - model=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_is_on is not None @property def unique_id(self) -> str: @@ -224,16 +219,11 @@ def unique_id(self) -> str: return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def is_on(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_is_on = self.entity_description.value_getter(self._api) except requests.exceptions.ConnectionError: _LOGGER.error("Unable to retrieve data from ViCare server") except ValueError: diff --git a/homeassistant/components/vicare/button.py b/homeassistant/components/vicare/button.py index ac025ff37d10fd..7fd8cccd3a45df 100644 --- a/homeassistant/components/vicare/button.py +++ b/homeassistant/components/vicare/button.py @@ -104,6 +104,13 @@ def __init__( 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.""" @@ -119,17 +126,6 @@ def press(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @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 unique_id(self) -> str: """Return unique ID for this device.""" diff --git a/homeassistant/components/vicare/climate.py b/homeassistant/components/vicare/climate.py index d5beff4b268dda..a9188adc964f95 100644 --- a/homeassistant/components/vicare/climate.py +++ b/homeassistant/components/vicare/climate.py @@ -36,13 +36,7 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -126,7 +120,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -149,35 +142,26 @@ class ViCareClimate(ClimateEntity): ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_HEATING_MIN + _attr_max_temp = VICARE_TEMP_HEATING_MAX + _attr_target_temperature_step = PRECISION_WHOLE + _attr_preset_modes = list(HA_TO_VICARE_PRESET_HEATING) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the climate device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None self._current_mode = None - self._current_temperature = None self._current_program = None - self._heating_type = heating_type self._current_action = None - - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @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(), + 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=self._device_config.getModel(), + model=device_config.getModel(), configuration_url="https://developer.viessmann.com/", ) @@ -193,27 +177,29 @@ def update(self) -> None: _supply_temperature = self._circuit.getSupplyTemperature() if _room_temperature is not None: - self._current_temperature = _room_temperature + self._attr_current_temperature = _room_temperature elif _supply_temperature is not None: - self._current_temperature = _supply_temperature + self._attr_current_temperature = _supply_temperature else: - self._current_temperature = None + self._attr_current_temperature = None with suppress(PyViCareNotSupportedFeatureError): self._current_program = self._circuit.getActiveProgram() with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = self._circuit.getCurrentDesiredTemperature() + self._attr_target_temperature = ( + self._circuit.getCurrentDesiredTemperature() + ) with suppress(PyViCareNotSupportedFeatureError): self._current_mode = self._circuit.getActiveMode() # Update the generic device attributes - self._attributes = {} - - self._attributes["room_temperature"] = _room_temperature - self._attributes["active_vicare_program"] = self._current_program - self._attributes["active_vicare_mode"] = self._current_mode + self._attributes = { + "room_temperature": _room_temperature, + "active_vicare_program": self._current_program, + "active_vicare_mode": self._current_mode, + } with suppress(PyViCareNotSupportedFeatureError): self._attributes[ @@ -248,21 +234,6 @@ def update(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def name(self): - """Return the name of the climate device.""" - return self._name - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - @property def hvac_mode(self) -> HVACMode | None: """Return current hvac mode.""" @@ -313,37 +284,17 @@ def hvac_action(self) -> HVACAction: return HVACAction.HEATING return HVACAction.IDLE - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_HEATING_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_HEATING_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._circuit.setProgramTemperature(self._current_program, temp) - self._target_temperature = temp + self._attr_target_temperature = temp @property def preset_mode(self): """Return the current preset mode, e.g., home, away, temp.""" return VICARE_TO_HA_PRESET_HEATING.get(self._current_program) - @property - def preset_modes(self): - """Return the available preset mode.""" - return list(HA_TO_VICARE_PRESET_HEATING) - def set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode and deactivate any existing programs.""" vicare_program = HA_TO_VICARE_PRESET_HEATING.get(preset_mode) diff --git a/homeassistant/components/vicare/sensor.py b/homeassistant/components/vicare/sensor.py index 24f23b0da0ad5d..d7ac7f25274ee6 100644 --- a/homeassistant/components/vicare/sensor.py +++ b/homeassistant/components/vicare/sensor.py @@ -673,7 +673,6 @@ def __init__( self._attr_name = name self._api = api self._device_config = device_config - self._state = None @property def device_info(self) -> DeviceInfo: @@ -689,7 +688,7 @@ def device_info(self) -> DeviceInfo: @property def available(self): """Return True if entity is available.""" - return self._state is not None + return self._attr_native_value is not None @property def unique_id(self) -> str: @@ -701,16 +700,13 @@ def unique_id(self) -> str: return f"{tmp_id}-{self._api.id}" return tmp_id - @property - def native_value(self): - """Return the state of the sensor.""" - return self._state - def update(self): """Update state of sensor.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._state = self.entity_description.value_getter(self._api) + self._attr_native_value = self.entity_description.value_getter( + self._api + ) if self.entity_description.unit_getter: vicare_unit = self.entity_description.unit_getter(self._api) diff --git a/homeassistant/components/vicare/water_heater.py b/homeassistant/components/vicare/water_heater.py index c0d77dd46b68df..3357d2e0a317a2 100644 --- a/homeassistant/components/vicare/water_heater.py +++ b/homeassistant/components/vicare/water_heater.py @@ -15,23 +15,12 @@ WaterHeaterEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - ATTR_TEMPERATURE, - PRECISION_TENTHS, - PRECISION_WHOLE, - UnitOfTemperature, -) +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 ( - CONF_HEATING_TYPE, - DOMAIN, - VICARE_API, - VICARE_DEVICE_CONFIG, - VICARE_NAME, -) +from .const import DOMAIN, VICARE_API, VICARE_DEVICE_CONFIG, VICARE_NAME _LOGGER = logging.getLogger(__name__) @@ -95,7 +84,6 @@ async def async_setup_entry( api, circuit, hass.data[DOMAIN][config_entry.entry_id][VICARE_DEVICE_CONFIG], - config_entry.data[CONF_HEATING_TYPE], ) entities.append(entity) @@ -107,30 +95,37 @@ class ViCareWater(WaterHeaterEntity): _attr_precision = PRECISION_TENTHS _attr_supported_features = WaterHeaterEntityFeature.TARGET_TEMPERATURE + _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_min_temp = VICARE_TEMP_WATER_MIN + _attr_max_temp = VICARE_TEMP_WATER_MAX + _attr_operation_list = list(HA_TO_VICARE_HVAC_DHW) - def __init__(self, name, api, circuit, device_config, heating_type): + def __init__(self, name, api, circuit, device_config): """Initialize the DHW water_heater device.""" - self._name = name - self._state = None + self._attr_name = name self._api = api self._circuit = circuit - self._device_config = device_config self._attributes = {} - self._target_temperature = None - self._current_temperature = None self._current_mode = None - self._heating_type = heating_type + 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.""" try: with suppress(PyViCareNotSupportedFeatureError): - self._current_temperature = ( + self._attr_current_temperature = ( self._api.getDomesticHotWaterStorageTemperature() ) with suppress(PyViCareNotSupportedFeatureError): - self._target_temperature = ( + self._attr_target_temperature = ( self._api.getDomesticHotWaterDesiredTemperature() ) @@ -146,69 +141,13 @@ def update(self) -> None: except PyViCareInvalidDataError as invalid_data_exception: _LOGGER.error("Invalid data from Vicare server: %s", invalid_data_exception) - @property - def unique_id(self) -> str: - """Return unique ID for this device.""" - return f"{self._device_config.getConfig().serial}-{self._circuit.id}" - - @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 name(self): - """Return the name of the water_heater device.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return UnitOfTemperature.CELSIUS - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._current_temperature - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - return self._target_temperature - def set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: self._api.setDomesticHotWaterTemperature(temp) - self._target_temperature = temp - - @property - def min_temp(self): - """Return the minimum temperature.""" - return VICARE_TEMP_WATER_MIN - - @property - def max_temp(self): - """Return the maximum temperature.""" - return VICARE_TEMP_WATER_MAX - - @property - def target_temperature_step(self) -> float: - """Set target temperature step to wholes.""" - return PRECISION_WHOLE + self._attr_target_temperature = temp @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" return VICARE_TO_HA_HVAC_DHW.get(self._current_mode) - - @property - def operation_list(self): - """Return the list of available operation modes.""" - return list(HA_TO_VICARE_HVAC_DHW) From af8fd6c2d9101ff299d293ed09f1df66f02c983c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 23:22:03 +0200 Subject: [PATCH 277/640] Restore airtouch4 codeowner (#99984) --- CODEOWNERS | 2 ++ homeassistant/components/airtouch4/manifest.json | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CODEOWNERS b/CODEOWNERS index ba792b07183d53..b4eb1e39072549 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -49,6 +49,8 @@ build.json @home-assistant/supervisor /tests/components/airthings/ @danielhiversen /homeassistant/components/airthings_ble/ @vincegio @LaStrada /tests/components/airthings_ble/ @vincegio @LaStrada +/homeassistant/components/airtouch4/ @samsinnamon +/tests/components/airtouch4/ @samsinnamon /homeassistant/components/airvisual/ @bachya /tests/components/airvisual/ @bachya /homeassistant/components/airvisual_pro/ @bachya diff --git a/homeassistant/components/airtouch4/manifest.json b/homeassistant/components/airtouch4/manifest.json index e845c278a543bd..8a1f947af64988 100644 --- a/homeassistant/components/airtouch4/manifest.json +++ b/homeassistant/components/airtouch4/manifest.json @@ -1,7 +1,7 @@ { "domain": "airtouch4", "name": "AirTouch 4", - "codeowners": [], + "codeowners": ["@samsinnamon"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airtouch4", "iot_class": "local_polling", From 081d0bdce59c3a0dcdeb8968350a57abfb8b9fc8 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sat, 9 Sep 2023 23:50:26 +0200 Subject: [PATCH 278/640] Bump plugwise to v0.32.2 (#99973) * Bump plugwise to v0.32.2 * Adapt number.py to the backend updates * Update related test-cases * Update plugwise test-fixtures * Update test_diagnostics.py --- .../components/plugwise/manifest.json | 2 +- homeassistant/components/plugwise/number.py | 13 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- .../plugwise/fixtures/adam_jip/all_data.json | 48 ++ .../all_data.json | 54 ++ .../anna_heatpump_heating/all_data.json | 6 + .../fixtures/m_adam_cooling/all_data.json | 12 + .../fixtures/m_adam_heating/all_data.json | 12 + .../m_anna_heatpump_cooling/all_data.json | 6 + .../m_anna_heatpump_idle/all_data.json | 6 + .../fixtures/p1v4_442_triple/all_data.json | 8 +- .../p1v4_442_triple/notifications.json | 6 +- .../fixtures/stretch_v31/all_data.json | 9 - tests/components/plugwise/test_diagnostics.py | 548 ++++++++++-------- tests/components/plugwise/test_number.py | 4 +- 16 files changed, 470 insertions(+), 268 deletions(-) diff --git a/homeassistant/components/plugwise/manifest.json b/homeassistant/components/plugwise/manifest.json index ef0f01b38f7146..e87e1f0c281dc3 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.31.9"], + "requirements": ["plugwise==0.32.2"], "zeroconf": ["_plugwise._tcp.local."] } diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 5979480d90f3a2..6fd3f7f92da5b7 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -27,7 +27,7 @@ class PlugwiseEntityDescriptionMixin: """Mixin values for Plugwise entities.""" - command: Callable[[Smile, str, float], Awaitable[None]] + command: Callable[[Smile, str, str, float], Awaitable[None]] @dataclass @@ -43,7 +43,9 @@ class PlugwiseNumberEntityDescription( PlugwiseNumberEntityDescription( key="maximum_boiler_temperature", translation_key="maximum_boiler_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -51,7 +53,9 @@ class PlugwiseNumberEntityDescription( PlugwiseNumberEntityDescription( key="max_dhw_temperature", translation_key="max_dhw_temperature", - command=lambda api, number, value: api.set_number_setpoint(number, value), + command=lambda api, number, dev_id, value: api.set_number_setpoint( + number, dev_id, value + ), device_class=NumberDeviceClass.TEMPERATURE, entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, @@ -94,6 +98,7 @@ def __init__( ) -> None: """Initiate Plugwise Number.""" super().__init__(coordinator, device_id) + self.device_id = device_id self.entity_description = description self._attr_unique_id = f"{device_id}-{description.key}" self._attr_mode = NumberMode.BOX @@ -109,6 +114,6 @@ def native_value(self) -> float: async def async_set_native_value(self, value: float) -> None: """Change to the new setpoint value.""" await self.entity_description.command( - self.coordinator.api, self.entity_description.key, value + self.coordinator.api, self.entity_description.key, self.device_id, value ) await self.coordinator.async_request_refresh() diff --git a/requirements_all.txt b/requirements_all.txt index 6c5e57e8f830a4..021ef7989b1521 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1439,7 +1439,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.2 # homeassistant.components.plum_lightpad plumlightpad==0.0.11 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9596bb81d3f4a0..66be97e6ae0ed1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1093,7 +1093,7 @@ plexauth==0.0.6 plexwebsocket==0.0.13 # homeassistant.components.plugwise -plugwise==0.31.9 +plugwise==0.32.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 177478f0fff15b..4dda9af3b54df2 100644 --- a/tests/components/plugwise/fixtures/adam_jip/all_data.json +++ b/tests/components/plugwise/fixtures/adam_jip/all_data.json @@ -20,6 +20,12 @@ "setpoint": 13.0, "temperature": 24.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -43,6 +49,12 @@ "temperature_difference": 2.0, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A07" }, @@ -60,6 +72,12 @@ "temperature_difference": 1.7, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A05" }, @@ -99,6 +117,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -122,6 +146,12 @@ "temperature_difference": 1.8, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -145,6 +175,12 @@ "setpoint": 13.0, "temperature": 30.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -187,6 +223,12 @@ "temperature_difference": 1.9, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A04" }, @@ -246,6 +288,12 @@ "setpoint": 9.0, "temperature": 27.4 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.01, 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 63f0012ea9211b..0cc28731ff4a8a 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 @@ -95,6 +95,12 @@ "temperature_difference": -0.4, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A17" }, @@ -123,6 +129,12 @@ "setpoint": 15.0, "temperature": 17.2 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -200,6 +212,12 @@ "temperature_difference": -0.2, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A09" }, @@ -217,6 +235,12 @@ "temperature_difference": 3.5, "valve_position": 100 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A02" }, @@ -245,6 +269,12 @@ "setpoint": 21.5, "temperature": 20.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -289,6 +319,12 @@ "temperature_difference": 0.1, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A10" }, @@ -317,6 +353,12 @@ "setpoint": 13.0, "temperature": 16.5 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -353,6 +395,12 @@ "temperature_difference": 0.0, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, @@ -387,6 +435,12 @@ "setpoint": 14.0, "temperature": 18.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, 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 49b5221233fd36..cdddfdb3439c03 100644 --- a/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json +++ b/tests/components/plugwise/fixtures/anna_heatpump_heating/all_data.json @@ -76,6 +76,12 @@ "setpoint": 20.5, "temperature": 19.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.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 92618a901890ed..ac7e602821e06c 100644 --- a/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_cooling/all_data.json @@ -40,6 +40,12 @@ "temperature_difference": 2.3, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -118,6 +124,12 @@ "setpoint_low": 20.0, "temperature": 239 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, 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 4345cf76a3a8a8..a4923b1c5490f5 100644 --- a/tests/components/plugwise/fixtures/m_adam_heating/all_data.json +++ b/tests/components/plugwise/fixtures/m_adam_heating/all_data.json @@ -45,6 +45,12 @@ "temperature_difference": 2.3, "valve_position": 0.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.1, + "upper_bound": 2.0 + }, "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A01" }, @@ -114,6 +120,12 @@ "setpoint": 15.0, "temperature": 17.9 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 0.0, "resolution": 0.01, 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 20f2db213bdb53..f98f253e9389f2 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 @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 26.3 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, 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 3a7bd2dae89655..56d26f67acb423 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 @@ -78,6 +78,12 @@ "setpoint_low": 20.5, "temperature": 23.0 }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": -0.5, + "upper_bound": 2.0 + }, "thermostat": { "lower_bound": 4.0, "resolution": 0.1, diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json index e9a3b4c68b96e0..d503bd3a59d267 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/all_data.json @@ -2,7 +2,7 @@ "devices": { "03e65b16e4b247a29ae0d75a78cb492e": { "binary_sensors": { - "plugwise_notification": false + "plugwise_notification": true }, "dev_class": "gateway", "firmware": "4.4.2", @@ -51,7 +51,11 @@ }, "gateway": { "gateway_id": "03e65b16e4b247a29ae0d75a78cb492e", - "notifications": {}, + "notifications": { + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } + }, "smile_name": "Smile P1" } } diff --git a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json index 0967ef424bce67..49db062035aa38 100644 --- a/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json +++ b/tests/components/plugwise/fixtures/p1v4_442_triple/notifications.json @@ -1 +1,5 @@ -{} +{ + "97a04c0c263049b29350a660b4cdd01e": { + "warning": "The Smile P1 is not connected to a smart meter." + } +} diff --git a/tests/components/plugwise/fixtures/stretch_v31/all_data.json b/tests/components/plugwise/fixtures/stretch_v31/all_data.json index c336a9cb9c2df2..8604aaae10e6cc 100644 --- a/tests/components/plugwise/fixtures/stretch_v31/all_data.json +++ b/tests/components/plugwise/fixtures/stretch_v31/all_data.json @@ -48,15 +48,6 @@ "vendor": "Plugwise", "zigbee_mac_address": "ABCD012345670A07" }, - "71e1944f2a944b26ad73323e399efef0": { - "dev_class": "switching", - "members": ["5ca521ac179d468e91d772eeeb8a2117"], - "model": "Switchgroup", - "name": "Test", - "switches": { - "relay": true - } - }, "aac7b735042c4832ac9ff33aae4f453b": { "dev_class": "dishwasher", "firmware": "2011-06-27T10:52:18+02:00", diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 5dde8a0e09ea3a..69f180692e2d28 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -31,89 +31,101 @@ async def test_diagnostics( }, }, "devices": { - "df4a4a8169904cdb9c03d61a21f42140": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Lisa", - "name": "Zone Lisa Bios", - "zigbee_mac_address": "ABCD012345670A06", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 13.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, + "02cf28bfec924855854c544690a609ef": { + "available": True, + "dev_class": "vcr", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "NVR", + "sensors": { + "electricity_consumed": 34.0, + "electricity_consumed_interval": 9.15, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, }, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A15", + }, + "21f2b542c49845e6bb416884c55778d6": { "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "select_schedule": "None", - "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": {"temperature": 16.5, "setpoint": 13.0, "battery": 67}, + "dev_class": "game_console", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "Playstation Smart Plug", + "sensors": { + "electricity_consumed": 82.6, + "electricity_consumed_interval": 8.6, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, + }, + "switches": {"lock": False, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A12", }, - "b310b72a0e354bfab43089919b9a88bf": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "zigbee_mac_address": "ABCD012345670A02", + "4a810418d5394b3f82727340b91ba740": { + "available": True, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "USG Smart Plug", + "sensors": { + "electricity_consumed": 8.5, + "electricity_consumed_interval": 0.0, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, + }, + "switches": {"lock": True, "relay": True}, "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A16", + }, + "675416a629f343c495449970e2ca37b5": { "available": True, + "dev_class": "router", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "Ziggo Modem", "sensors": { - "temperature": 26.0, - "setpoint": 21.5, - "temperature_difference": 3.5, - "valve_position": 100, + "electricity_consumed": 12.2, + "electricity_consumed_interval": 2.97, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, }, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A01", }, - "a2c3583e0a6349358998b760cea82d2a": { + "680423ff840043738f42cc7f1ff97a36": { + "available": True, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", + "location": "08963fec7c53423ca5680aa4cb502c63", "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "zigbee_mac_address": "ABCD012345670A09", - "vendor": "Plugwise", - "available": True, + "name": "Thermostatic Radiator Badkamer", "sensors": { - "temperature": 17.2, - "setpoint": 13.0, - "battery": 62, - "temperature_difference": -0.2, + "battery": 51, + "setpoint": 14.0, + "temperature": 19.1, + "temperature_difference": -0.4, "valve_position": 0.0, }, - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Lisa", - "name": "Zone Lisa WK", - "zigbee_mac_address": "ABCD012345670A07", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 21.5, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A17", + }, + "6a3bf693d05e48e0b460c815a4fdd09d": { + "active_preset": "asleep", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "home", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -121,69 +133,39 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "GF7 Woonkamer", - "last_used": "GF7 Woonkamer", - "mode": "auto", - "sensors": {"temperature": 20.9, "setpoint": 21.5, "battery": 34}, - }, - "fe799307f1624099878210aa0b9f1475": { - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "zigbee_mac_address": "ABCD012345670101", - "vendor": "Plugwise", - "select_regulation_mode": "heating", - "binary_sensors": {"plugwise_notification": True}, - "sensors": {"outdoor_temperature": 7.81}, - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", + "last_used": "CV Jessie", "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "zigbee_mac_address": "ABCD012345670A10", - "vendor": "Plugwise", - "available": True, - "sensors": { - "temperature": 17.1, + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Jessie", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "CV Jessie", + "sensors": {"battery": 37, "setpoint": 15.0, "temperature": 17.2}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, "setpoint": 15.0, - "battery": 62, - "temperature_difference": 0.1, - "valve_position": 0.0, + "upper_bound": 99.9, }, - }, - "21f2b542c49845e6bb416884c55778d6": { - "dev_class": "game_console", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Playstation Smart Plug", - "zigbee_mac_address": "ABCD012345670A12", "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 82.6, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": False}, + "zigbee_mac_address": "ABCD012345670A03", }, "78d1126fc4c743db81b61c20e88342a7": { + "available": True, "dev_class": "central_heating_pump", "firmware": "2019-06-21T02:00:00+02:00", "location": "c50f167537524366a5af7aa3942feb1e", "model": "Plug", "name": "CV Pomp", - "zigbee_mac_address": "ABCD012345670A05", - "vendor": "Plugwise", - "available": True, "sensors": { "electricity_consumed": 35.6, "electricity_consumed_interval": 7.37, @@ -191,105 +173,88 @@ async def test_diagnostics( "electricity_produced_interval": 0.0, }, "switches": {"relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A05", }, "90986d591dcd426cae3ec3e8111ff730": { + "binary_sensors": {"heating_state": True}, "dev_class": "heater_central", "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", "model": "Unknown", "name": "OnOff", - "binary_sensors": {"heating_state": True}, "sensors": { - "water_temperature": 70.0, "intended_boiler_temperature": 70.0, "modulation_level": 1, + "water_temperature": 70.0, }, }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "zigbee_mac_address": "ABCD012345670A14", - "vendor": "Plugwise", + "a28f588dc4a049a483fd03a30361ad3a": { "available": True, - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "4a810418d5394b3f82727340b91ba740": { - "dev_class": "router", + "dev_class": "settop", "firmware": "2019-06-21T02:00:00+02:00", "location": "cd143c07248f491493cea0533bc3d669", "model": "Plug", - "name": "USG Smart Plug", - "zigbee_mac_address": "ABCD012345670A16", - "vendor": "Plugwise", - "available": True, + "name": "Fibaro HC2", "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, + "electricity_consumed": 12.5, + "electricity_consumed_interval": 3.8, "electricity_produced": 0.0, "electricity_produced_interval": 0.0, }, - "switches": {"relay": True, "lock": True}, - }, - "02cf28bfec924855854c544690a609ef": { - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "zigbee_mac_address": "ABCD012345670A15", + "switches": {"lock": True, "relay": True}, "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A13", + }, + "a2c3583e0a6349358998b760cea82d2a": { "available": True, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "12493538af164a409c6a1c79e38afe1c", + "model": "Tom/Floor", + "name": "Bios Cv Thermostatic Radiator ", "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, + "battery": 62, + "setpoint": 13.0, + "temperature": 17.2, + "temperature_difference": -0.2, + "valve_position": 0.0, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, }, - "switches": {"relay": True, "lock": True}, - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "dev_class": "settop", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Fibaro HC2", - "zigbee_mac_address": "ABCD012345670A13", "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A09", + }, + "b310b72a0e354bfab43089919b9a88bf": { "available": True, + "dev_class": "thermo_sensor", + "firmware": "2019-03-27T01:00:00+01:00", + "hardware": "1", + "location": "c50f167537524366a5af7aa3942feb1e", + "model": "Tom/Floor", + "name": "Floor kraan", "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, + "setpoint": 21.5, + "temperature": 26.0, + "temperature_difference": 3.5, + "valve_position": 100, }, - "switches": {"relay": True, "lock": True}, - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "zigbee_mac_address": "ABCD012345670A03", - "vendor": "Plugwise", - "thermostat": { - "setpoint": 15.0, - "lower_bound": 0.0, - "upper_bound": 99.9, - "resolution": 0.01, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A02", + }, + "b59bcebaf94b499ea7d46e4a66fb62d8": { + "active_preset": "home", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "asleep", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -297,47 +262,112 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "CV Jessie", - "last_used": "CV Jessie", + "dev_class": "zone_thermostat", + "firmware": "2016-08-02T02:00:00+02:00", + "hardware": "255", + "last_used": "GF7 Woonkamer", + "location": "c50f167537524366a5af7aa3942feb1e", "mode": "auto", - "sensors": {"temperature": 17.2, "setpoint": 15.0, "battery": 37}, + "model": "Lisa", + "name": "Zone Lisa WK", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "GF7 Woonkamer", + "sensors": {"battery": 34, "setpoint": 21.5, "temperature": 20.9}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 21.5, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A07", }, - "680423ff840043738f42cc7f1ff97a36": { + "cd0ddb54ef694e11ac18ed1cbce5dbbd": { + "available": True, + "dev_class": "vcr", + "firmware": "2019-06-21T02:00:00+02:00", + "location": "cd143c07248f491493cea0533bc3d669", + "model": "Plug", + "name": "NAS", + "sensors": { + "electricity_consumed": 16.5, + "electricity_consumed_interval": 0.5, + "electricity_produced": 0.0, + "electricity_produced_interval": 0.0, + }, + "switches": {"lock": True, "relay": True}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A14", + }, + "d3da73bde12a47d5a6b8f9dad971f2ec": { + "available": True, "dev_class": "thermo_sensor", "firmware": "2019-03-27T01:00:00+01:00", "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", + "location": "82fa13f017d240daa0d0ea1775420f24", "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "zigbee_mac_address": "ABCD012345670A17", - "vendor": "Plugwise", - "available": True, + "name": "Thermostatic Radiator Jessie", "sensors": { - "temperature": 19.1, - "setpoint": 14.0, - "battery": 51, - "temperature_difference": -0.4, + "battery": 62, + "setpoint": 15.0, + "temperature": 17.1, + "temperature_difference": 0.1, "valve_position": 0.0, }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A10", }, - "f1fee6043d3642a9b0a65297455f008e": { + "df4a4a8169904cdb9c03d61a21f42140": { + "active_preset": "away", + "available": True, + "available_schedules": [ + "CV Roan", + "Bios Schema met Film Avond", + "GF7 Woonkamer", + "Badkamer Schema", + "CV Jessie", + ], "dev_class": "zone_thermostat", "firmware": "2016-10-27T02:00:00+02:00", "hardware": "255", - "location": "08963fec7c53423ca5680aa4cb502c63", + "last_used": "Badkamer Schema", + "location": "12493538af164a409c6a1c79e38afe1c", + "mode": "heat", "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "zigbee_mac_address": "ABCD012345670A08", - "vendor": "Plugwise", + "name": "Zone Lisa Bios", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": {"battery": 67, "setpoint": 13.0, "temperature": 16.5}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, "thermostat": { - "setpoint": 14.0, "lower_bound": 0.0, - "upper_bound": 99.9, "resolution": 0.01, + "setpoint": 13.0, + "upper_bound": 99.9, }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A06", + }, + "e7693eb9582644e5b865dba8d4447cf1": { + "active_preset": "no_frost", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "away", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -345,46 +375,41 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "Badkamer Schema", - "last_used": "Badkamer Schema", - "mode": "auto", - "sensors": {"temperature": 18.9, "setpoint": 14.0, "battery": 92}, - }, - "675416a629f343c495449970e2ca37b5": { - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Ziggo Modem", - "zigbee_mac_address": "ABCD012345670A01", - "vendor": "Plugwise", - "available": True, - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True, "lock": True}, - }, - "e7693eb9582644e5b865dba8d4447cf1": { "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", "name": "CV Kraan Garage", - "zigbee_mac_address": "ABCD012345670A11", - "vendor": "Plugwise", - "thermostat": { + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "None", + "sensors": { + "battery": 68, "setpoint": 5.5, + "temperature": 15.6, + "temperature_difference": 0.0, + "valve_position": 0.0, + }, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, + }, + "thermostat": { "lower_bound": 0.0, - "upper_bound": 100.0, "resolution": 0.01, + "setpoint": 5.5, + "upper_bound": 100.0, }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A11", + }, + "f1fee6043d3642a9b0a65297455f008e": { + "active_preset": "away", "available": True, - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "active_preset": "no_frost", "available_schedules": [ "CV Roan", "Bios Schema met Film Avond", @@ -392,16 +417,45 @@ async def test_diagnostics( "Badkamer Schema", "CV Jessie", ], - "select_schedule": "None", + "dev_class": "zone_thermostat", + "firmware": "2016-10-27T02:00:00+02:00", + "hardware": "255", "last_used": "Badkamer Schema", - "mode": "heat", - "sensors": { - "temperature": 15.6, - "setpoint": 5.5, - "battery": 68, - "temperature_difference": 0.0, - "valve_position": 0.0, + "location": "08963fec7c53423ca5680aa4cb502c63", + "mode": "auto", + "model": "Lisa", + "name": "Zone Thermostat Badkamer", + "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], + "select_schedule": "Badkamer Schema", + "sensors": {"battery": 92, "setpoint": 14.0, "temperature": 18.9}, + "temperature_offset": { + "lower_bound": -2.0, + "resolution": 0.1, + "setpoint": 0.0, + "upper_bound": 2.0, }, + "thermostat": { + "lower_bound": 0.0, + "resolution": 0.01, + "setpoint": 14.0, + "upper_bound": 99.9, + }, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670A08", + }, + "fe799307f1624099878210aa0b9f1475": { + "binary_sensors": {"plugwise_notification": True}, + "dev_class": "gateway", + "firmware": "3.0.15", + "hardware": "AME Smile 2.0 board", + "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", + "mac_address": "012345670001", + "model": "Gateway", + "name": "Adam", + "select_regulation_mode": "heating", + "sensors": {"outdoor_temperature": 7.81}, + "vendor": "Plugwise", + "zigbee_mac_address": "ABCD012345670101", }, }, } diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index 9ca64e104d3133..bccf257a433090 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -38,7 +38,7 @@ async def test_anna_max_boiler_temp_change( assert mock_smile_anna.set_number_setpoint.call_count == 1 mock_smile_anna.set_number_setpoint.assert_called_with( - "maximum_boiler_temperature", 65.0 + "maximum_boiler_temperature", "1cbf783bb11e4a7c8a6843dee3a86927", 65.0 ) @@ -67,5 +67,5 @@ async def test_adam_dhw_setpoint_change( assert mock_smile_adam_2.set_number_setpoint.call_count == 1 mock_smile_adam_2.set_number_setpoint.assert_called_with( - "max_dhw_temperature", 55.0 + "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 ) From b66437ff7b4a6a3d6e4051d22d7d554f71195b3b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:11 -0500 Subject: [PATCH 279/640] Bump yalexs-ble to 2.3.0 (#100007) --- 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 cd2737adca398b..a2d460d12ec2f8 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.8.0", "yalexs-ble==2.2.3"] + "requirements": ["yalexs==1.8.0", "yalexs-ble==2.3.0"] } diff --git a/homeassistant/components/yalexs_ble/manifest.json b/homeassistant/components/yalexs_ble/manifest.json index 3aefeea048a75a..cbff581d29608d 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.2.3"] + "requirements": ["yalexs-ble==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 021ef7989b1521..5697dc28c946ac 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2737,7 +2737,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august yalexs==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 66be97e6ae0ed1..3416452cb9b62b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2022,7 +2022,7 @@ yalesmartalarmclient==0.3.9 # homeassistant.components.august # homeassistant.components.yalexs_ble -yalexs-ble==2.2.3 +yalexs-ble==2.3.0 # homeassistant.components.august yalexs==1.8.0 From b370244ed412444b8a3bb5a5e23f14dd055ef1da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:31 -0500 Subject: [PATCH 280/640] Switch ESPHome Bluetooth to use loop.create_future() (#100010) --- homeassistant/components/esphome/bluetooth/device.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/esphome/bluetooth/device.py b/homeassistant/components/esphome/bluetooth/device.py index 8d060151dbf254..c76562a2145a3d 100644 --- a/homeassistant/components/esphome/bluetooth/device.py +++ b/homeassistant/components/esphome/bluetooth/device.py @@ -21,6 +21,7 @@ class ESPHomeBluetoothDevice: _ble_connection_free_futures: list[asyncio.Future[int]] = field( default_factory=list ) + loop: asyncio.AbstractEventLoop = field(default_factory=asyncio.get_running_loop) @callback def async_update_ble_connection_limits(self, free: int, limit: int) -> None: @@ -49,6 +50,6 @@ async def wait_for_ble_connections_free(self) -> int: """Wait until there are free BLE connections.""" if self.ble_connections_free > 0: return self.ble_connections_free - fut: asyncio.Future[int] = asyncio.Future() + fut: asyncio.Future[int] = self.loop.create_future() self._ble_connection_free_futures.append(fut) return await fut From e3f228ea52f962b3a11b22f824be41d42e2f9bff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 17:34:49 -0500 Subject: [PATCH 281/640] Switch config_entries to use loop.create_future() (#100011) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f627b804989f9f..046f403642eca3 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -859,7 +859,7 @@ async def async_init( flow_id = uuid_util.random_uuid_hex() if context["source"] == SOURCE_IMPORT: - init_done: asyncio.Future[None] = asyncio.Future() + init_done: asyncio.Future[None] = self.hass.loop.create_future() self._pending_import_flows.setdefault(handler, {})[flow_id] = init_done task = asyncio.create_task( From 4bc079b2195da93e35d593db47554f7c6e4b0457 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:17 +0200 Subject: [PATCH 282/640] Use snapshot assertion in Plugwise diagnostic test (#100008) * Use snapshot assertion in Plugwise diagnostic test * Use snapshot assertion in Plugwise diagnostic test --- .../plugwise/snapshots/test_diagnostics.ambr | 516 ++++++++++++++++++ tests/components/plugwise/test_diagnostics.py | 450 +-------------- 2 files changed, 523 insertions(+), 443 deletions(-) create mode 100644 tests/components/plugwise/snapshots/test_diagnostics.ambr diff --git a/tests/components/plugwise/snapshots/test_diagnostics.ambr b/tests/components/plugwise/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..da6e896442164b --- /dev/null +++ b/tests/components/plugwise/snapshots/test_diagnostics.ambr @@ -0,0 +1,516 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'devices': dict({ + '02cf28bfec924855854c544690a609ef': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NVR', + 'sensors': dict({ + 'electricity_consumed': 34.0, + 'electricity_consumed_interval': 9.15, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A15', + }), + '21f2b542c49845e6bb416884c55778d6': dict({ + 'available': True, + 'dev_class': 'game_console', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Playstation Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 82.6, + 'electricity_consumed_interval': 8.6, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': False, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A12', + }), + '4a810418d5394b3f82727340b91ba740': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'USG Smart Plug', + 'sensors': dict({ + 'electricity_consumed': 8.5, + 'electricity_consumed_interval': 0.0, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A16', + }), + '675416a629f343c495449970e2ca37b5': dict({ + 'available': True, + 'dev_class': 'router', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Ziggo Modem', + 'sensors': dict({ + 'electricity_consumed': 12.2, + 'electricity_consumed_interval': 2.97, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A01', + }), + '680423ff840043738f42cc7f1ff97a36': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Badkamer', + 'sensors': dict({ + 'battery': 51, + 'setpoint': 14.0, + 'temperature': 19.1, + 'temperature_difference': -0.4, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A17', + }), + '6a3bf693d05e48e0b460c815a4fdd09d': dict({ + 'active_preset': 'asleep', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'CV Jessie', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Jessie', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'CV Jessie', + 'sensors': dict({ + 'battery': 37, + 'setpoint': 15.0, + 'temperature': 17.2, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 15.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A03', + }), + '78d1126fc4c743db81b61c20e88342a7': dict({ + 'available': True, + 'dev_class': 'central_heating_pump', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Plug', + 'name': 'CV Pomp', + 'sensors': dict({ + 'electricity_consumed': 35.6, + 'electricity_consumed_interval': 7.37, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A05', + }), + '90986d591dcd426cae3ec3e8111ff730': dict({ + 'binary_sensors': dict({ + 'heating_state': True, + }), + 'dev_class': 'heater_central', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'model': 'Unknown', + 'name': 'OnOff', + 'sensors': dict({ + 'intended_boiler_temperature': 70.0, + 'modulation_level': 1, + 'water_temperature': 70.0, + }), + }), + 'a28f588dc4a049a483fd03a30361ad3a': dict({ + 'available': True, + 'dev_class': 'settop', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'Fibaro HC2', + 'sensors': dict({ + 'electricity_consumed': 12.5, + 'electricity_consumed_interval': 3.8, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A13', + }), + 'a2c3583e0a6349358998b760cea82d2a': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'model': 'Tom/Floor', + 'name': 'Bios Cv Thermostatic Radiator ', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 13.0, + 'temperature': 17.2, + 'temperature_difference': -0.2, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A09', + }), + 'b310b72a0e354bfab43089919b9a88bf': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'model': 'Tom/Floor', + 'name': 'Floor kraan', + 'sensors': dict({ + 'setpoint': 21.5, + 'temperature': 26.0, + 'temperature_difference': 3.5, + 'valve_position': 100, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A02', + }), + 'b59bcebaf94b499ea7d46e4a66fb62d8': dict({ + 'active_preset': 'home', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-08-02T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'GF7 Woonkamer', + 'location': 'c50f167537524366a5af7aa3942feb1e', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Lisa WK', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'GF7 Woonkamer', + 'sensors': dict({ + 'battery': 34, + 'setpoint': 21.5, + 'temperature': 20.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 21.5, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A07', + }), + 'cd0ddb54ef694e11ac18ed1cbce5dbbd': dict({ + 'available': True, + 'dev_class': 'vcr', + 'firmware': '2019-06-21T02:00:00+02:00', + 'location': 'cd143c07248f491493cea0533bc3d669', + 'model': 'Plug', + 'name': 'NAS', + 'sensors': dict({ + 'electricity_consumed': 16.5, + 'electricity_consumed_interval': 0.5, + 'electricity_produced': 0.0, + 'electricity_produced_interval': 0.0, + }), + 'switches': dict({ + 'lock': True, + 'relay': True, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A14', + }), + 'd3da73bde12a47d5a6b8f9dad971f2ec': dict({ + 'available': True, + 'dev_class': 'thermo_sensor', + 'firmware': '2019-03-27T01:00:00+01:00', + 'hardware': '1', + 'location': '82fa13f017d240daa0d0ea1775420f24', + 'model': 'Tom/Floor', + 'name': 'Thermostatic Radiator Jessie', + 'sensors': dict({ + 'battery': 62, + 'setpoint': 15.0, + 'temperature': 17.1, + 'temperature_difference': 0.1, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A10', + }), + 'df4a4a8169904cdb9c03d61a21f42140': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '12493538af164a409c6a1c79e38afe1c', + 'mode': 'heat', + 'model': 'Lisa', + 'name': 'Zone Lisa Bios', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 67, + 'setpoint': 13.0, + 'temperature': 16.5, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 13.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A06', + }), + 'e7693eb9582644e5b865dba8d4447cf1': dict({ + 'active_preset': 'no_frost', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + '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', + 'name': 'CV Kraan Garage', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'None', + 'sensors': dict({ + 'battery': 68, + 'setpoint': 5.5, + 'temperature': 15.6, + 'temperature_difference': 0.0, + 'valve_position': 0.0, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 5.5, + 'upper_bound': 100.0, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A11', + }), + 'f1fee6043d3642a9b0a65297455f008e': dict({ + 'active_preset': 'away', + 'available': True, + 'available_schedules': list([ + 'CV Roan', + 'Bios Schema met Film Avond', + 'GF7 Woonkamer', + 'Badkamer Schema', + 'CV Jessie', + ]), + 'dev_class': 'zone_thermostat', + 'firmware': '2016-10-27T02:00:00+02:00', + 'hardware': '255', + 'last_used': 'Badkamer Schema', + 'location': '08963fec7c53423ca5680aa4cb502c63', + 'mode': 'auto', + 'model': 'Lisa', + 'name': 'Zone Thermostat Badkamer', + 'preset_modes': list([ + 'home', + 'asleep', + 'away', + 'vacation', + 'no_frost', + ]), + 'select_schedule': 'Badkamer Schema', + 'sensors': dict({ + 'battery': 92, + 'setpoint': 14.0, + 'temperature': 18.9, + }), + 'temperature_offset': dict({ + 'lower_bound': -2.0, + 'resolution': 0.1, + 'setpoint': 0.0, + 'upper_bound': 2.0, + }), + 'thermostat': dict({ + 'lower_bound': 0.0, + 'resolution': 0.01, + 'setpoint': 14.0, + 'upper_bound': 99.9, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670A08', + }), + 'fe799307f1624099878210aa0b9f1475': dict({ + 'binary_sensors': dict({ + 'plugwise_notification': True, + }), + 'dev_class': 'gateway', + 'firmware': '3.0.15', + 'hardware': 'AME Smile 2.0 board', + 'location': '1f9dcf83fd4e4b66b72ff787957bfe5d', + 'mac_address': '012345670001', + 'model': 'Gateway', + 'name': 'Adam', + 'select_regulation_mode': 'heating', + 'sensors': dict({ + 'outdoor_temperature': 7.81, + }), + 'vendor': 'Plugwise', + 'zigbee_mac_address': 'ABCD012345670101', + }), + }), + 'gateway': dict({ + 'cooling_present': False, + 'gateway_id': 'fe799307f1624099878210aa0b9f1475', + 'heater_id': '90986d591dcd426cae3ec3e8111ff730', + 'notifications': dict({ + 'af82e4ccf9c548528166d38e560662a4': dict({ + 'warning': "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device.", + }), + }), + 'smile_name': 'Adam', + }), + }) +# --- diff --git a/tests/components/plugwise/test_diagnostics.py b/tests/components/plugwise/test_diagnostics.py index 69f180692e2d28..045b8641f695c2 100644 --- a/tests/components/plugwise/test_diagnostics.py +++ b/tests/components/plugwise/test_diagnostics.py @@ -1,6 +1,8 @@ """Tests for the diagnostics data provided by the Plugwise integration.""" from unittest.mock import MagicMock +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -13,449 +15,11 @@ async def test_diagnostics( hass_client: ClientSessionGenerator, mock_smile_adam: MagicMock, init_integration: MockConfigEntry, + snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - assert await get_diagnostics_for_config_entry( - hass, hass_client, init_integration - ) == { - "gateway": { - "smile_name": "Adam", - "gateway_id": "fe799307f1624099878210aa0b9f1475", - "heater_id": "90986d591dcd426cae3ec3e8111ff730", - "cooling_present": False, - "notifications": { - "af82e4ccf9c548528166d38e560662a4": { - "warning": "Node Plug (with MAC address 000D6F000D13CB01, in room 'n.a.') has been unreachable since 23:03 2020-01-18. Please check the connection and restart the device." - } - }, - }, - "devices": { - "02cf28bfec924855854c544690a609ef": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NVR", - "sensors": { - "electricity_consumed": 34.0, - "electricity_consumed_interval": 9.15, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A15", - }, - "21f2b542c49845e6bb416884c55778d6": { - "available": True, - "dev_class": "game_console", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Playstation Smart Plug", - "sensors": { - "electricity_consumed": 82.6, - "electricity_consumed_interval": 8.6, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": False, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A12", - }, - "4a810418d5394b3f82727340b91ba740": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "USG Smart Plug", - "sensors": { - "electricity_consumed": 8.5, - "electricity_consumed_interval": 0.0, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A16", - }, - "675416a629f343c495449970e2ca37b5": { - "available": True, - "dev_class": "router", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Ziggo Modem", - "sensors": { - "electricity_consumed": 12.2, - "electricity_consumed_interval": 2.97, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A01", - }, - "680423ff840043738f42cc7f1ff97a36": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "08963fec7c53423ca5680aa4cb502c63", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Badkamer", - "sensors": { - "battery": 51, - "setpoint": 14.0, - "temperature": 19.1, - "temperature_difference": -0.4, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A17", - }, - "6a3bf693d05e48e0b460c815a4fdd09d": { - "active_preset": "asleep", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "CV Jessie", - "location": "82fa13f017d240daa0d0ea1775420f24", - "mode": "auto", - "model": "Lisa", - "name": "Zone Thermostat Jessie", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "CV Jessie", - "sensors": {"battery": 37, "setpoint": 15.0, "temperature": 17.2}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 15.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A03", - }, - "78d1126fc4c743db81b61c20e88342a7": { - "available": True, - "dev_class": "central_heating_pump", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Plug", - "name": "CV Pomp", - "sensors": { - "electricity_consumed": 35.6, - "electricity_consumed_interval": 7.37, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A05", - }, - "90986d591dcd426cae3ec3e8111ff730": { - "binary_sensors": {"heating_state": True}, - "dev_class": "heater_central", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "model": "Unknown", - "name": "OnOff", - "sensors": { - "intended_boiler_temperature": 70.0, - "modulation_level": 1, - "water_temperature": 70.0, - }, - }, - "a28f588dc4a049a483fd03a30361ad3a": { - "available": True, - "dev_class": "settop", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "Fibaro HC2", - "sensors": { - "electricity_consumed": 12.5, - "electricity_consumed_interval": 3.8, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A13", - }, - "a2c3583e0a6349358998b760cea82d2a": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "12493538af164a409c6a1c79e38afe1c", - "model": "Tom/Floor", - "name": "Bios Cv Thermostatic Radiator ", - "sensors": { - "battery": 62, - "setpoint": 13.0, - "temperature": 17.2, - "temperature_difference": -0.2, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A09", - }, - "b310b72a0e354bfab43089919b9a88bf": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "c50f167537524366a5af7aa3942feb1e", - "model": "Tom/Floor", - "name": "Floor kraan", - "sensors": { - "setpoint": 21.5, - "temperature": 26.0, - "temperature_difference": 3.5, - "valve_position": 100, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A02", - }, - "b59bcebaf94b499ea7d46e4a66fb62d8": { - "active_preset": "home", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-08-02T02:00:00+02:00", - "hardware": "255", - "last_used": "GF7 Woonkamer", - "location": "c50f167537524366a5af7aa3942feb1e", - "mode": "auto", - "model": "Lisa", - "name": "Zone Lisa WK", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "GF7 Woonkamer", - "sensors": {"battery": 34, "setpoint": 21.5, "temperature": 20.9}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 21.5, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A07", - }, - "cd0ddb54ef694e11ac18ed1cbce5dbbd": { - "available": True, - "dev_class": "vcr", - "firmware": "2019-06-21T02:00:00+02:00", - "location": "cd143c07248f491493cea0533bc3d669", - "model": "Plug", - "name": "NAS", - "sensors": { - "electricity_consumed": 16.5, - "electricity_consumed_interval": 0.5, - "electricity_produced": 0.0, - "electricity_produced_interval": 0.0, - }, - "switches": {"lock": True, "relay": True}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A14", - }, - "d3da73bde12a47d5a6b8f9dad971f2ec": { - "available": True, - "dev_class": "thermo_sensor", - "firmware": "2019-03-27T01:00:00+01:00", - "hardware": "1", - "location": "82fa13f017d240daa0d0ea1775420f24", - "model": "Tom/Floor", - "name": "Thermostatic Radiator Jessie", - "sensors": { - "battery": 62, - "setpoint": 15.0, - "temperature": 17.1, - "temperature_difference": 0.1, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A10", - }, - "df4a4a8169904cdb9c03d61a21f42140": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "Badkamer Schema", - "location": "12493538af164a409c6a1c79e38afe1c", - "mode": "heat", - "model": "Lisa", - "name": "Zone Lisa Bios", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": {"battery": 67, "setpoint": 13.0, "temperature": 16.5}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 13.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A06", - }, - "e7693eb9582644e5b865dba8d4447cf1": { - "active_preset": "no_frost", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "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", - "name": "CV Kraan Garage", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "None", - "sensors": { - "battery": 68, - "setpoint": 5.5, - "temperature": 15.6, - "temperature_difference": 0.0, - "valve_position": 0.0, - }, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 5.5, - "upper_bound": 100.0, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A11", - }, - "f1fee6043d3642a9b0a65297455f008e": { - "active_preset": "away", - "available": True, - "available_schedules": [ - "CV Roan", - "Bios Schema met Film Avond", - "GF7 Woonkamer", - "Badkamer Schema", - "CV Jessie", - ], - "dev_class": "zone_thermostat", - "firmware": "2016-10-27T02:00:00+02:00", - "hardware": "255", - "last_used": "Badkamer Schema", - "location": "08963fec7c53423ca5680aa4cb502c63", - "mode": "auto", - "model": "Lisa", - "name": "Zone Thermostat Badkamer", - "preset_modes": ["home", "asleep", "away", "vacation", "no_frost"], - "select_schedule": "Badkamer Schema", - "sensors": {"battery": 92, "setpoint": 14.0, "temperature": 18.9}, - "temperature_offset": { - "lower_bound": -2.0, - "resolution": 0.1, - "setpoint": 0.0, - "upper_bound": 2.0, - }, - "thermostat": { - "lower_bound": 0.0, - "resolution": 0.01, - "setpoint": 14.0, - "upper_bound": 99.9, - }, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670A08", - }, - "fe799307f1624099878210aa0b9f1475": { - "binary_sensors": {"plugwise_notification": True}, - "dev_class": "gateway", - "firmware": "3.0.15", - "hardware": "AME Smile 2.0 board", - "location": "1f9dcf83fd4e4b66b72ff787957bfe5d", - "mac_address": "012345670001", - "model": "Gateway", - "name": "Adam", - "select_regulation_mode": "heating", - "sensors": {"outdoor_temperature": 7.81}, - "vendor": "Plugwise", - "zigbee_mac_address": "ABCD012345670101", - }, - }, - } + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, init_integration) + == snapshot + ) From 6c613fd2558f91a8f28e38f4894102f64f9511bf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:43 +0200 Subject: [PATCH 283/640] Move static attributes outside of ws66i constructor (#99922) Move static attributes outside of ws66i cosntructor --- .../components/ws66i/media_player.py | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/ws66i/media_player.py b/homeassistant/components/ws66i/media_player.py index b5c87fbc0f31da..7119002cbc4f27 100644 --- a/homeassistant/components/ws66i/media_player.py +++ b/homeassistant/components/ws66i/media_player.py @@ -46,6 +46,14 @@ class Ws66iZone(CoordinatorEntity[Ws66iDataUpdateCoordinator], MediaPlayerEntity _attr_has_entity_name = True _attr_name = None + _attr_supported_features = ( + MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.SELECT_SOURCE + ) def __init__( self, @@ -64,18 +72,10 @@ def __init__( self._zone_id_idx: int = data_idx self._status: ZoneStatus = coordinator.data[data_idx] self._attr_source_list = ws66i_data.sources.name_list - self._attr_unique_id = f"{entry_id}_{self._zone_id}" - self._attr_supported_features = ( - MediaPlayerEntityFeature.VOLUME_MUTE - | MediaPlayerEntityFeature.VOLUME_SET - | MediaPlayerEntityFeature.VOLUME_STEP - | MediaPlayerEntityFeature.TURN_ON - | MediaPlayerEntityFeature.TURN_OFF - | MediaPlayerEntityFeature.SELECT_SOURCE - ) + self._attr_unique_id = f"{entry_id}_{zone_id}" self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, str(self.unique_id))}, - name=f"Zone {self._zone_id}", + name=f"Zone {zone_id}", manufacturer="Soundavo", model="WS66i 6-Zone Amplifier", ) From 8de3945bd46f3c46c2f5ea6645ca01ac910ba0b6 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 00:38:57 +0200 Subject: [PATCH 284/640] Fix renamed code owner for Versasense (#99976) Fix renamed code owner --- CODEOWNERS | 2 +- homeassistant/components/versasense/manifest.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index b4eb1e39072549..6f7a0099494149 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1365,7 +1365,7 @@ build.json @home-assistant/supervisor /tests/components/venstar/ @garbled1 /homeassistant/components/verisure/ @frenck @niro1987 /tests/components/verisure/ @frenck @niro1987 -/homeassistant/components/versasense/ @flamm3blemuff1n +/homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus /homeassistant/components/vesync/ @markperdue @webdjoe @thegardenmonkey diff --git a/homeassistant/components/versasense/manifest.json b/homeassistant/components/versasense/manifest.json index 0dd63218939467..421a46bc2f6fde 100644 --- a/homeassistant/components/versasense/manifest.json +++ b/homeassistant/components/versasense/manifest.json @@ -1,7 +1,7 @@ { "domain": "versasense", "name": "VersaSense", - "codeowners": ["@flamm3blemuff1n"], + "codeowners": ["@imstevenxyz"], "documentation": "https://www.home-assistant.io/integrations/versasense", "iot_class": "local_polling", "loggers": ["pyversasense"], From 092580a3ed23f51565bd11aed3d2421d24381b2f Mon Sep 17 00:00:00 2001 From: Kevin Worrel <37058192+dieselrabbit@users.noreply.github.com> Date: Sat, 9 Sep 2023 15:39:54 -0700 Subject: [PATCH 285/640] Bump screenlogicpy to v0.9.0 (#92475) Co-authored-by: J. Nick Koston --- .coveragerc | 3 +- .../components/screenlogic/__init__.py | 184 ++-- .../components/screenlogic/binary_sensor.py | 269 ++--- .../components/screenlogic/climate.py | 131 +-- .../components/screenlogic/config_flow.py | 21 +- homeassistant/components/screenlogic/const.py | 47 +- .../components/screenlogic/coordinator.py | 97 ++ homeassistant/components/screenlogic/data.py | 304 ++++++ .../components/screenlogic/diagnostics.py | 2 +- .../components/screenlogic/entity.py | 122 +-- homeassistant/components/screenlogic/light.py | 58 +- .../components/screenlogic/manifest.json | 2 +- .../components/screenlogic/number.py | 210 +++- .../components/screenlogic/sensor.py | 419 ++++---- .../components/screenlogic/switch.py | 61 +- homeassistant/components/screenlogic/util.py | 40 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/screenlogic/__init__.py | 66 ++ tests/components/screenlogic/conftest.py | 27 + .../screenlogic/fixtures/data_full_chem.json | 880 ++++++++++++++++ .../fixtures/data_min_entity_cleanup.json | 38 + .../fixtures/data_min_migration.json | 151 +++ .../snapshots/test_diagnostics.ambr | 960 ++++++++++++++++++ .../screenlogic/test_config_flow.py | 2 +- tests/components/screenlogic/test_data.py | 91 ++ .../screenlogic/test_diagnostics.py | 56 + tests/components/screenlogic/test_init.py | 236 +++++ 28 files changed, 3825 insertions(+), 656 deletions(-) create mode 100644 homeassistant/components/screenlogic/coordinator.py create mode 100644 homeassistant/components/screenlogic/data.py create mode 100644 homeassistant/components/screenlogic/util.py create mode 100644 tests/components/screenlogic/conftest.py create mode 100644 tests/components/screenlogic/fixtures/data_full_chem.json create mode 100644 tests/components/screenlogic/fixtures/data_min_entity_cleanup.json create mode 100644 tests/components/screenlogic/fixtures/data_min_migration.json create mode 100644 tests/components/screenlogic/snapshots/test_diagnostics.ambr create mode 100644 tests/components/screenlogic/test_data.py create mode 100644 tests/components/screenlogic/test_diagnostics.py create mode 100644 tests/components/screenlogic/test_init.py diff --git a/.coveragerc b/.coveragerc index d9cb511e86e4e6..ecc835106ffbf9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1071,9 +1071,10 @@ omit = homeassistant/components/saj/sensor.py homeassistant/components/satel_integra/* homeassistant/components/schluter/* - homeassistant/components/screenlogic/__init__.py homeassistant/components/screenlogic/binary_sensor.py homeassistant/components/screenlogic/climate.py + homeassistant/components/screenlogic/coordinator.py + homeassistant/components/screenlogic/const.py homeassistant/components/screenlogic/entity.py homeassistant/components/screenlogic/light.py homeassistant/components/screenlogic/number.py diff --git a/homeassistant/components/screenlogic/__init__.py b/homeassistant/components/screenlogic/__init__.py index 3370c196c3c902..298e1c1ca0010f 100644 --- a/homeassistant/components/screenlogic/__init__.py +++ b/homeassistant/components/screenlogic/__init__.py @@ -1,27 +1,22 @@ """The Screenlogic integration.""" -from datetime import timedelta import logging from typing import Any from screenlogicpy import ScreenLogicError, ScreenLogicGateway -from screenlogicpy.const import ( - DATA as SL_DATA, - EQUIPMENT, - SL_GATEWAY_IP, - SL_GATEWAY_NAME, - SL_GATEWAY_PORT, -) +from screenlogicpy.const.data import SHARED_VALUES from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.helpers import entity_registry as er +from homeassistant.util import slugify -from .config_flow import async_discover_gateways_by_unique_id, name_for_mac -from .const import DEFAULT_SCAN_INTERVAL, DOMAIN +from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator, async_get_connect_info +from .data import ENTITY_MIGRATIONS from .services import async_load_screenlogic_services, async_unload_screenlogic_services +from .util import generate_unique_id _LOGGER = logging.getLogger(__name__) @@ -44,12 +39,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Screenlogic from a config entry.""" + + await _async_migrate_entries(hass, entry) + gateway = ScreenLogicGateway() connect_info = await async_get_connect_info(hass, entry) try: await gateway.async_connect(**connect_info) + await gateway.async_update() except ScreenLogicError as ex: raise ConfigEntryNotReady(ex.msg) from ex @@ -88,83 +87,88 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def async_get_connect_info( - hass: HomeAssistant, entry: ConfigEntry -) -> dict[str, str | int]: - """Construct connect_info from configuration entry and returns it to caller.""" - mac = entry.unique_id - # Attempt to rediscover gateway to follow IP changes - discovered_gateways = await async_discover_gateways_by_unique_id(hass) - if mac in discovered_gateways: - return discovered_gateways[mac] - - _LOGGER.warning("Gateway rediscovery failed") - # Static connection defined or fallback from discovery - return { - SL_GATEWAY_NAME: name_for_mac(mac), - SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], - SL_GATEWAY_PORT: entry.data[CONF_PORT], - } - - -class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): - """Class to manage the data update for the Screenlogic component.""" - - def __init__( - self, - hass: HomeAssistant, - *, - config_entry: ConfigEntry, - gateway: ScreenLogicGateway, - ) -> None: - """Initialize the Screenlogic Data Update Coordinator.""" - self.config_entry = config_entry - self.gateway = gateway - - interval = timedelta( - seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) - ) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=interval, - # Debounced option since the device takes - # a moment to reflect the knock-on changes - request_refresh_debouncer=Debouncer( - hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False - ), +async def _async_migrate_entries( + hass: HomeAssistant, config_entry: ConfigEntry +) -> None: + """Migrate to new entity names.""" + entity_registry = er.async_get(hass) + + for entry in er.async_entries_for_config_entry( + entity_registry, config_entry.entry_id + ): + source_mac, source_key = entry.unique_id.split("_", 1) + + source_index = None + if ( + len(key_parts := source_key.rsplit("_", 1)) == 2 + and key_parts[1].isdecimal() + ): + source_key, source_index = key_parts + + _LOGGER.debug( + "Checking migration status for '%s' against key '%s'", + entry.unique_id, + source_key, ) - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - async def _async_update_configured_data(self) -> None: - """Update data sets based on equipment config.""" - equipment_flags = self.gateway.get_data()[SL_DATA.KEY_CONFIG]["equipment_flags"] - if not self.gateway.is_client: - await self.gateway.async_get_status() - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - await self.gateway.async_get_chemistry() - - await self.gateway.async_get_pumps() - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - await self.gateway.async_get_scg() - - async def _async_update_data(self) -> None: - """Fetch data from the Screenlogic gateway.""" - assert self.config_entry is not None - try: - if not self.gateway.is_connected: - connect_info = await async_get_connect_info( - self.hass, self.config_entry - ) - await self.gateway.async_connect(**connect_info) + if source_key not in ENTITY_MIGRATIONS: + continue - await self._async_update_configured_data() - except ScreenLogicError as ex: - if self.gateway.is_connected: - await self.gateway.async_disconnect() - raise UpdateFailed(ex.msg) from ex + _LOGGER.debug( + "Evaluating migration of '%s' from migration key '%s'", + entry.entity_id, + source_key, + ) + migrations = ENTITY_MIGRATIONS[source_key] + updates: dict[str, Any] = {} + new_key = migrations["new_key"] + if new_key in SHARED_VALUES: + if (device := migrations.get("device")) is None: + _LOGGER.debug( + "Shared key '%s' is missing required migration data 'device'", + new_key, + ) + continue + assert device is not None and ( + device != "pump" or (device == "pump" and source_index is not None) + ) + new_unique_id = ( + f"{source_mac}_{generate_unique_id(device, source_index, new_key)}" + ) + else: + new_unique_id = entry.unique_id.replace(source_key, new_key) + + if new_unique_id and new_unique_id != entry.unique_id: + if existing_entity_id := entity_registry.async_get_entity_id( + entry.domain, entry.platform, new_unique_id + ): + _LOGGER.debug( + "Cannot migrate '%s' to unique_id '%s', already exists for entity '%s'. Aborting", + entry.unique_id, + new_unique_id, + existing_entity_id, + ) + continue + updates["new_unique_id"] = new_unique_id + + if (old_name := migrations.get("old_name")) is not None: + assert old_name + new_name = migrations["new_name"] + if (s_old_name := slugify(old_name)) in entry.entity_id: + new_entity_id = entry.entity_id.replace(s_old_name, slugify(new_name)) + if new_entity_id and new_entity_id != entry.entity_id: + updates["new_entity_id"] = new_entity_id + + if entry.original_name and old_name in entry.original_name: + new_original_name = entry.original_name.replace(old_name, new_name) + if new_original_name and new_original_name != entry.original_name: + updates["original_name"] = new_original_name + + if updates: + _LOGGER.debug( + "Migrating entity '%s' unique_id from '%s' to '%s'", + entry.entity_id, + entry.unique_id, + new_unique_id, + ) + entity_registry.async_update_entity(entry.entity_id, **updates) diff --git a/homeassistant/components/screenlogic/binary_sensor.py b/homeassistant/components/screenlogic/binary_sensor.py index 305775844942a2..337d308d8d9437 100644 --- a/homeassistant/components/screenlogic/binary_sensor.py +++ b/homeassistant/components/screenlogic/binary_sensor.py @@ -1,28 +1,97 @@ """Support for a ScreenLogic Binary Sensor.""" -from screenlogicpy.const import CODE, DATA as SL_DATA, DEVICE_TYPE, EQUIPMENT, ON_OFF +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, ON_OFF +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE from homeassistant.components.binary_sensor import ( + DOMAIN, BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + SupportedValueParameters, + build_base_entity_description, + iterate_expand_group_wildcard, + preprocess_supported_values, +) +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, +) +from .util import cleanup_excluded_entity, generate_unique_id + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class SupportedBinarySensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic binary sensor entity.""" + + device_class: BinarySensorDeviceClass | None = None + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.ACTIVE_ALERT: SupportedBinarySensorValueParameters(), + VALUE.CLEANER_DELAY: SupportedBinarySensorValueParameters(), + VALUE.FREEZE_MODE: SupportedBinarySensorValueParameters(), + VALUE.POOL_DELAY: SupportedBinarySensorValueParameters(), + VALUE.SPA_DELAY: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.ALARM: { + VALUE.FLOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.ORP_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_HIGH_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_LOW_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PH_SUPPLY_ALARM: SupportedBinarySensorValueParameters(), + VALUE.PROBE_FAULT_ALARM: SupportedBinarySensorValueParameters(), + }, + GROUP.ALERT: { + VALUE.ORP_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LIMIT: SupportedBinarySensorValueParameters(), + VALUE.PH_LOCKOUT: SupportedBinarySensorValueParameters(), + }, + GROUP.WATER_BALANCE: { + VALUE.CORROSIVE: SupportedBinarySensorValueParameters(), + VALUE.SCALING: SupportedBinarySensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.STATE: SupportedBinarySensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = {DEVICE_TYPE.ALARM: BinarySensorDeviceClass.PROBLEM} -SUPPORTED_CONFIG_BINARY_SENSORS = ( - "freeze_mode", - "pool_delay", - "spa_delay", - "cleaner_delay", -) - async def async_setup_entry( hass: HomeAssistant, @@ -30,132 +99,92 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicBinarySensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicBinarySensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - gateway_data = coordinator.gateway_data - config = gateway_data[SL_DATA.KEY_CONFIG] - - # Generic binary sensor - entities.append( - ScreenLogicStatusBinarySensor(coordinator, "chem_alarm", CODE.STATUS_CHANGED) - ) - - entities.extend( - [ - ScreenlogicConfigBinarySensor(coordinator, cfg_sensor, CODE.STATUS_CHANGED) - for cfg_sensor in config - if cfg_sensor in SUPPORTED_CONFIG_BINARY_SENSORS - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_INTELLICHEM: - chemistry = gateway_data[SL_DATA.KEY_CHEMISTRY] - # IntelliChem alarm sensors - entities.extend( - [ - ScreenlogicChemistryAlarmBinarySensor( - coordinator, chem_alarm, CODE.CHEMISTRY_CHANGED + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedBinarySensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + } + + if ( + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: + entities.append( + ScreenLogicPushBinarySensor( + coordinator, + ScreenLogicPushBinarySensorDescription( + subscription_code=sub_code, **entity_description_kwargs + ), ) - for chem_alarm in chemistry[SL_DATA.KEY_ALERTS] - if not chem_alarm.startswith("_") - ] - ) - - # Intellichem notification sensors - entities.extend( - [ - ScreenlogicChemistryNotificationBinarySensor( - coordinator, chem_notif, CODE.CHEMISTRY_CHANGED + ) + else: + entities.append( + ScreenLogicBinarySensor( + coordinator, + ScreenLogicBinarySensorDescription(**entity_description_kwargs), ) - for chem_notif in chemistry[SL_DATA.KEY_NOTIFICATIONS] - if not chem_notif.startswith("_") - ] - ) - - if config["equipment_flags"] & EQUIPMENT.FLAG_CHLORINATOR: - # SCG binary sensor - entities.append(ScreenlogicSCGBinarySensor(coordinator, "scg_status")) + ) async_add_entities(entities) -class ScreenLogicBinarySensorEntity(ScreenlogicEntity, BinarySensorEntity): - """Base class for all ScreenLogic binary sensor entities.""" +@dataclass +class ScreenLogicBinarySensorDescription( + BinarySensorEntityDescription, ScreenLogicEntityDescription +): + """A class that describes ScreenLogic binary sensor eneites.""" - _attr_has_entity_name = True - _attr_entity_category = EntityCategory.DIAGNOSTIC - @property - def name(self) -> str | None: - """Return the sensor name.""" - return self.sensor["name"] +class ScreenLogicBinarySensor(ScreenlogicEntity, BinarySensorEntity): + """Base class for all ScreenLogic binary sensor entities.""" - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) + entity_description: ScreenLogicBinarySensorDescription + _attr_has_entity_name = True @property def is_on(self) -> bool: """Determine if the sensor is on.""" - return self.sensor["value"] == ON_OFF.ON - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - - -class ScreenLogicStatusBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a basic ScreenLogic sensor entity.""" + return self.entity_data[ATTR.VALUE] == ON_OFF.ON -class ScreenlogicChemistryAlarmBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity +@dataclass +class ScreenLogicPushBinarySensorDescription( + ScreenLogicBinarySensorDescription, ScreenLogicPushEntityDescription ): - """Representation of a ScreenLogic IntelliChem alarm binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_ALERTS][ - self._data_key - ] + """Describes a ScreenLogicPushBinarySensor.""" -class ScreenlogicChemistryNotificationBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic IntelliChem notification binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][SL_DATA.KEY_NOTIFICATIONS][ - self._data_key - ] - - -class ScreenlogicSCGBinarySensor(ScreenLogicBinarySensorEntity): - """Representation of a ScreenLogic SCG binary sensor entity.""" - - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] - - -class ScreenlogicConfigBinarySensor( - ScreenLogicBinarySensorEntity, ScreenLogicPushEntity -): - """Representation of a ScreenLogic config data binary sensor entity.""" +class ScreenLogicPushBinarySensor(ScreenLogicPushEntity, ScreenLogicBinarySensor): + """Representation of a basic ScreenLogic sensor entity.""" - @property - def sensor(self) -> dict: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG][self._data_key] + entity_description: ScreenLogicPushBinarySensorDescription diff --git a/homeassistant/components/screenlogic/climate.py b/homeassistant/components/screenlogic/climate.py index cea546262aea2d..889c8617274421 100644 --- a/homeassistant/components/screenlogic/climate.py +++ b/homeassistant/components/screenlogic/climate.py @@ -1,12 +1,18 @@ """Support for a ScreenLogic heating device.""" +from dataclasses import dataclass import logging from typing import Any -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, HEAT_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.heat import HEAT_MODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.climate import ( ATTR_PRESET_MODE, ClimateEntity, + ClimateEntityDescription, ClimateEntityFeature, HVACAction, HVACMode, @@ -18,9 +24,9 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenLogicPushEntity +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicPushEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -41,81 +47,88 @@ async def async_setup_entry( ) -> None: """Set up entry.""" entities = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - for body in coordinator.gateway_data[SL_DATA.KEY_BODIES]: - entities.append(ScreenLogicClimate(coordinator, body)) + gateway = coordinator.gateway + + for body_index, body_data in gateway.get_data(DEVICE.BODY).items(): + body_path = (DEVICE.BODY, body_index) + entities.append( + ScreenLogicClimate( + coordinator, + ScreenLogicClimateDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=body_path, + key=body_index, + name=body_data[VALUE.HEAT_STATE][ATTR.NAME], + ), + ) + ) async_add_entities(entities) +@dataclass +class ScreenLogicClimateDescription( + ClimateEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic climate entity.""" + + class ScreenLogicClimate(ScreenLogicPushEntity, ClimateEntity, RestoreEntity): """Represents a ScreenLogic climate entity.""" - _attr_has_entity_name = True - + entity_description: ScreenLogicClimateDescription _attr_hvac_modes = SUPPORTED_MODES _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.PRESET_MODE ) - def __init__(self, coordinator, body): + def __init__(self, coordinator, entity_description) -> None: """Initialize a ScreenLogic climate entity.""" - super().__init__(coordinator, body, CODE.STATUS_CHANGED) + super().__init__(coordinator, entity_description) self._configured_heat_modes = [] # Is solar listed as available equipment? - if self.gateway_data["config"]["equipment_flags"] & EQUIPMENT.FLAG_SOLAR: + if EQUIPMENT_FLAG.SOLAR in self.gateway.equipment_flags: self._configured_heat_modes.extend( [HEAT_MODE.SOLAR, HEAT_MODE.SOLAR_PREFERRED] ) self._configured_heat_modes.append(HEAT_MODE.HEATER) - self._last_preset = None - - @property - def name(self) -> str: - """Name of the heater.""" - return self.body["heat_status"]["name"] - @property - def min_temp(self) -> float: - """Minimum allowed temperature.""" - return self.body["min_set_point"]["value"] - - @property - def max_temp(self) -> float: - """Maximum allowed temperature.""" - return self.body["max_set_point"]["value"] + self._attr_min_temp = self.entity_data[ATTR.MIN_SETPOINT] + self._attr_max_temp = self.entity_data[ATTR.MAX_SETPOINT] + self._last_preset = None @property def current_temperature(self) -> float: """Return water temperature.""" - return self.body["last_temperature"]["value"] + return self.entity_data[VALUE.LAST_TEMPERATURE][ATTR.VALUE] @property def target_temperature(self) -> float: """Target temperature.""" - return self.body["heat_set_point"]["value"] + return self.entity_data[VALUE.HEAT_SETPOINT][ATTR.VALUE] @property def temperature_unit(self) -> str: """Return the unit of measurement.""" - if self.config_data["is_celsius"]["value"] == 1: + if self.gateway.temperature_unit == UNIT.CELSIUS: return UnitOfTemperature.CELSIUS return UnitOfTemperature.FAHRENHEIT @property def hvac_mode(self) -> HVACMode: """Return the current hvac mode.""" - if self.body["heat_mode"]["value"] > 0: + if self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE] > 0: return HVACMode.HEAT return HVACMode.OFF @property def hvac_action(self) -> HVACAction: """Return the current action of the heater.""" - if self.body["heat_status"]["value"] > 0: + if self.entity_data[VALUE.HEAT_STATE][ATTR.VALUE] > 0: return HVACAction.HEATING if self.hvac_mode == HVACMode.HEAT: return HVACAction.IDLE @@ -125,15 +138,13 @@ def hvac_action(self) -> HVACAction: def preset_mode(self) -> str: """Return current/last preset mode.""" if self.hvac_mode == HVACMode.OFF: - return HEAT_MODE.NAME_FOR_NUM[self._last_preset] - return HEAT_MODE.NAME_FOR_NUM[self.body["heat_mode"]["value"]] + return HEAT_MODE(self._last_preset).title + return HEAT_MODE(self.entity_data[VALUE.HEAT_MODE][ATTR.VALUE]).title @property def preset_modes(self) -> list[str]: """All available presets.""" - return [ - HEAT_MODE.NAME_FOR_NUM[mode_num] for mode_num in self._configured_heat_modes - ] + return [HEAT_MODE(mode_num).title for mode_num in self._configured_heat_modes] async def async_set_temperature(self, **kwargs: Any) -> None: """Change the setpoint of the heater.""" @@ -145,7 +156,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: ): raise HomeAssistantError( f"Failed to set_temperature {temperature} on body" - f" {self.body['body_type']['value']}" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) _LOGGER.debug("Set temperature for body %s to %s", self._data_key, temperature) @@ -154,28 +165,33 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if hvac_mode == HVACMode.OFF: mode = HEAT_MODE.OFF else: - mode = HEAT_MODE.NUM_FOR_NAME[self.preset_mode] + mode = HEAT_MODE.parse(self.preset_mode) - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_hvac_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_hvac_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set hvac_mode on body %s to %s", self._data_key, mode.name) async def async_set_preset_mode(self, preset_mode: str) -> None: """Set the preset mode.""" - _LOGGER.debug("Setting last_preset to %s", HEAT_MODE.NUM_FOR_NAME[preset_mode]) - self._last_preset = mode = HEAT_MODE.NUM_FOR_NAME[preset_mode] + mode = HEAT_MODE.parse(preset_mode) + _LOGGER.debug("Setting last_preset to %s", mode.name) + self._last_preset = mode.value if self.hvac_mode == HVACMode.OFF: return - if not await self.gateway.async_set_heat_mode(int(self._data_key), int(mode)): + if not await self.gateway.async_set_heat_mode( + int(self._data_key), int(mode.value) + ): raise HomeAssistantError( - f"Failed to set_preset_mode {mode} on body" - f" {self.body['body_type']['value']}" + f"Failed to set_preset_mode {mode.name} on body" + f" {self.entity_data[ATTR.BODY_TYPE][ATTR.VALUE]}" ) - _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode) + _LOGGER.debug("Set preset_mode on body %s to %s", self._data_key, mode.name) async def async_added_to_hass(self) -> None: """Run when entity is about to be added.""" @@ -189,21 +205,16 @@ async def async_added_to_hass(self) -> None: prev_state is not None and prev_state.attributes.get(ATTR_PRESET_MODE) is not None ): + mode = HEAT_MODE.parse(prev_state.attributes.get(ATTR_PRESET_MODE)) _LOGGER.debug( "Startup setting last_preset to %s from prev_state", - HEAT_MODE.NUM_FOR_NAME[prev_state.attributes.get(ATTR_PRESET_MODE)], + mode.name, ) - self._last_preset = HEAT_MODE.NUM_FOR_NAME[ - prev_state.attributes.get(ATTR_PRESET_MODE) - ] + self._last_preset = mode.value else: + mode = HEAT_MODE.parse(self._configured_heat_modes[0]) _LOGGER.debug( "Startup setting last_preset to default (%s)", - self._configured_heat_modes[0], + mode.name, ) - self._last_preset = self._configured_heat_modes[0] - - @property - def body(self): - """Shortcut to access body data.""" - return self.gateway_data[SL_DATA.KEY_BODIES][self._data_key] + self._last_preset = mode.value diff --git a/homeassistant/components/screenlogic/config_flow.py b/homeassistant/components/screenlogic/config_flow.py index 77040bdb21682e..25d00e3a2ce102 100644 --- a/homeassistant/components/screenlogic/config_flow.py +++ b/homeassistant/components/screenlogic/config_flow.py @@ -2,9 +2,10 @@ from __future__ import annotations import logging +from typing import Any from screenlogicpy import ScreenLogicError, discovery -from screenlogicpy.const import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT from screenlogicpy.requests import login import voluptuous as vol @@ -64,10 +65,10 @@ class ScreenlogicConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): VERSION = 1 - def __init__(self): + def __init__(self) -> None: """Initialize ScreenLogic ConfigFlow.""" - self.discovered_gateways = {} - self.discovered_ip = None + self.discovered_gateways: dict[str, dict[str, Any]] = {} + self.discovered_ip: str | None = None @staticmethod @callback @@ -77,7 +78,7 @@ def async_get_options_flow( """Get the options flow for ScreenLogic.""" return ScreenLogicOptionsFlowHandler(config_entry) - async def async_step_user(self, user_input=None): + async def async_step_user(self, user_input=None) -> FlowResult: """Handle the start of the config flow.""" self.discovered_gateways = await async_discover_gateways_by_unique_id(self.hass) return await self.async_step_gateway_select() @@ -93,7 +94,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes self.context["title_placeholders"] = {"name": discovery_info.hostname} return await self.async_step_gateway_entry() - async def async_step_gateway_select(self, user_input=None): + async def async_step_gateway_select(self, user_input=None) -> FlowResult: """Handle the selection of a discovered ScreenLogic gateway.""" existing = self._async_current_ids() unconfigured_gateways = { @@ -105,7 +106,7 @@ async def async_step_gateway_select(self, user_input=None): if not unconfigured_gateways: return await self.async_step_gateway_entry() - errors = {} + errors: dict[str, str] = {} if user_input is not None: if user_input[GATEWAY_SELECT_KEY] == GATEWAY_MANUAL_ENTRY: return await self.async_step_gateway_entry() @@ -140,9 +141,9 @@ async def async_step_gateway_select(self, user_input=None): description_placeholders={}, ) - async def async_step_gateway_entry(self, user_input=None): + async def async_step_gateway_entry(self, user_input=None) -> FlowResult: """Handle the manual entry of a ScreenLogic gateway.""" - errors = {} + errors: dict[str, str] = {} ip_address = self.discovered_ip port = 80 @@ -186,7 +187,7 @@ def __init__(self, config_entry: config_entries.ConfigEntry) -> None: """Init the screen logic options flow.""" self.config_entry = config_entry - async def async_step_init(self, user_input=None): + async def async_step_init(self, user_input=None) -> FlowResult: """Manage the options.""" if user_input is not None: return self.async_create_entry( diff --git a/homeassistant/components/screenlogic/const.py b/homeassistant/components/screenlogic/const.py index e4a5ea82186e8f..8181e0f612aa10 100644 --- a/homeassistant/components/screenlogic/const.py +++ b/homeassistant/components/screenlogic/const.py @@ -1,25 +1,48 @@ """Constants for the ScreenLogic integration.""" -from screenlogicpy.const import CIRCUIT_FUNCTION, COLOR_MODE +from screenlogicpy.const.common import UNIT +from screenlogicpy.device_const.circuit import FUNCTION +from screenlogicpy.device_const.system import COLOR_MODE +from homeassistant.const import ( + CONCENTRATION_PARTS_PER_MILLION, + PERCENTAGE, + REVOLUTIONS_PER_MINUTE, + UnitOfElectricPotential, + UnitOfPower, + UnitOfTemperature, + UnitOfTime, +) from homeassistant.util import slugify +ScreenLogicDataPath = tuple[str | int, ...] + DOMAIN = "screenlogic" DEFAULT_SCAN_INTERVAL = 30 MIN_SCAN_INTERVAL = 10 SERVICE_SET_COLOR_MODE = "set_color_mode" ATTR_COLOR_MODE = "color_mode" -SUPPORTED_COLOR_MODES = { - slugify(name): num for num, name in COLOR_MODE.NAME_FOR_NUM.items() -} +SUPPORTED_COLOR_MODES = {slugify(cm.name): cm.value for cm in COLOR_MODE} LIGHT_CIRCUIT_FUNCTIONS = { - CIRCUIT_FUNCTION.COLOR_WHEEL, - CIRCUIT_FUNCTION.DIMMER, - CIRCUIT_FUNCTION.INTELLIBRITE, - CIRCUIT_FUNCTION.LIGHT, - CIRCUIT_FUNCTION.MAGICSTREAM, - CIRCUIT_FUNCTION.PHOTONGEN, - CIRCUIT_FUNCTION.SAL_LIGHT, - CIRCUIT_FUNCTION.SAM_LIGHT, + FUNCTION.COLOR_WHEEL, + FUNCTION.DIMMER, + FUNCTION.INTELLIBRITE, + FUNCTION.LIGHT, + FUNCTION.MAGICSTREAM, + FUNCTION.PHOTONGEN, + FUNCTION.SAL_LIGHT, + FUNCTION.SAM_LIGHT, +} + +SL_UNIT_TO_HA_UNIT = { + UNIT.CELSIUS: UnitOfTemperature.CELSIUS, + UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, + UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, + UNIT.WATT: UnitOfPower.WATT, + UNIT.HOUR: UnitOfTime.HOURS, + UNIT.SECOND: UnitOfTime.SECONDS, + UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, + UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, + UNIT.PERCENT: PERCENTAGE, } diff --git a/homeassistant/components/screenlogic/coordinator.py b/homeassistant/components/screenlogic/coordinator.py new file mode 100644 index 00000000000000..74f4992717152b --- /dev/null +++ b/homeassistant/components/screenlogic/coordinator.py @@ -0,0 +1,97 @@ +"""ScreenlogicDataUpdateCoordinator definition.""" +from datetime import timedelta +import logging + +from screenlogicpy import ScreenLogicError, ScreenLogicGateway +from screenlogicpy.const.common import SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL +from homeassistant.core import HomeAssistant +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .config_flow import async_discover_gateways_by_unique_id, name_for_mac +from .const import DEFAULT_SCAN_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +REQUEST_REFRESH_DELAY = 2 +HEATER_COOLDOWN_DELAY = 6 + + +async def async_get_connect_info( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, str | int]: + """Construct connect_info from configuration entry and returns it to caller.""" + mac = entry.unique_id + # Attempt to rediscover gateway to follow IP changes + discovered_gateways = await async_discover_gateways_by_unique_id(hass) + if mac in discovered_gateways: + return discovered_gateways[mac] + + _LOGGER.debug("Gateway rediscovery failed for %s", entry.title) + # Static connection defined or fallback from discovery + return { + SL_GATEWAY_NAME: name_for_mac(mac), + SL_GATEWAY_IP: entry.data[CONF_IP_ADDRESS], + SL_GATEWAY_PORT: entry.data[CONF_PORT], + } + + +class ScreenlogicDataUpdateCoordinator(DataUpdateCoordinator[None]): + """Class to manage the data update for the Screenlogic component.""" + + def __init__( + self, + hass: HomeAssistant, + *, + config_entry: ConfigEntry, + gateway: ScreenLogicGateway, + ) -> None: + """Initialize the Screenlogic Data Update Coordinator.""" + self.config_entry = config_entry + self.gateway = gateway + + interval = timedelta( + seconds=config_entry.options.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + ) + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=interval, + # Debounced option since the device takes + # a moment to reflect the knock-on changes + request_refresh_debouncer=Debouncer( + hass, _LOGGER, cooldown=REQUEST_REFRESH_DELAY, immediate=False + ), + ) + + async def _async_update_configured_data(self) -> None: + """Update data sets based on equipment config.""" + if not self.gateway.is_client: + await self.gateway.async_get_status() + if EQUIPMENT_FLAG.INTELLICHEM in self.gateway.equipment_flags: + await self.gateway.async_get_chemistry() + + await self.gateway.async_get_pumps() + if EQUIPMENT_FLAG.CHLORINATOR in self.gateway.equipment_flags: + await self.gateway.async_get_scg() + + async def _async_update_data(self) -> None: + """Fetch data from the Screenlogic gateway.""" + assert self.config_entry is not None + try: + if not self.gateway.is_connected: + connect_info = await async_get_connect_info( + self.hass, self.config_entry + ) + await self.gateway.async_connect(**connect_info) + + await self._async_update_configured_data() + except ScreenLogicError as ex: + if self.gateway.is_connected: + await self.gateway.async_disconnect() + raise UpdateFailed(ex.msg) from ex diff --git a/homeassistant/components/screenlogic/data.py b/homeassistant/components/screenlogic/data.py new file mode 100644 index 00000000000000..5679b7e4dc99d3 --- /dev/null +++ b/homeassistant/components/screenlogic/data.py @@ -0,0 +1,304 @@ +"""Support for configurable supported data values for the ScreenLogic integration.""" +from collections.abc import Callable, Generator +from dataclasses import dataclass +from enum import StrEnum +from typing import Any + +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, VALUE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG + +from homeassistant.const import EntityCategory + +from .const import SL_UNIT_TO_HA_UNIT, ScreenLogicDataPath + + +class PathPart(StrEnum): + """Placeholders for local data_path values.""" + + DEVICE = "!device" + KEY = "!key" + INDEX = "!index" + VALUE = "!sensor" + + +ScreenLogicDataPathTemplate = tuple[PathPart | str | int, ...] + + +class ScreenLogicRule: + """Represents a base default passing rule.""" + + def __init__( + self, test: Callable[..., bool] = lambda gateway, data_path: True + ) -> None: + """Initialize a ScreenLogic rule.""" + self._test = test + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Method to check the rule.""" + return self._test(gateway, data_path) + + +class ScreenLogicDataRule(ScreenLogicRule): + """Represents a data rule.""" + + def __init__( + self, test: Callable[..., bool], test_path_template: tuple[PathPart, ...] + ) -> None: + """Initialize a ScreenLogic data rule.""" + self._test_path_template = test_path_template + super().__init__(test) + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's data.""" + test_path = realize_path_template(self._test_path_template, data_path) + return self._test(gateway.get_data(*test_path)) + + +class ScreenLogicEquipmentRule(ScreenLogicRule): + """Represents an equipment flag rule.""" + + def test(self, gateway: ScreenLogicGateway, data_path: ScreenLogicDataPath) -> bool: + """Check the rule against the gateway's equipment flags.""" + return self._test(gateway.equipment_flags) + + +@dataclass +class SupportedValueParameters: + """Base supported values for ScreenLogic Entities.""" + + enabled: ScreenLogicRule = ScreenLogicRule() + included: ScreenLogicRule = ScreenLogicRule() + subscription_code: int | None = None + entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC + + +SupportedValueDescriptions = dict[str, SupportedValueParameters] + +SupportedGroupDescriptions = dict[int | str, SupportedValueDescriptions] + +SupportedDeviceDescriptions = dict[str, SupportedGroupDescriptions] + + +DEVICE_INCLUSION_RULES = { + DEVICE.PUMP: ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.DATA] != 0, + (PathPart.DEVICE, PathPart.INDEX), + ), + DEVICE.INTELLICHEM: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags, + ), + DEVICE.SCG: ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.CHLORINATOR in flags, + ), +} + +DEVICE_SUBSCRIPTION = { + DEVICE.CONTROLLER: CODE.STATUS_CHANGED, + DEVICE.INTELLICHEM: CODE.CHEMISTRY_CHANGED, +} + + +# not run-time +def get_ha_unit(entity_data: dict) -> StrEnum | str | None: + """Return a Home Assistant unit of measurement from a UNIT.""" + sl_unit = entity_data.get(ATTR.UNIT) + return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) + + +# partial run-time +def realize_path_template( + template_path: ScreenLogicDataPathTemplate, data_path: ScreenLogicDataPath +) -> ScreenLogicDataPath: + """Create a new data path using a template and an existing data path. + + Construct new ScreenLogicDataPath from data_path using + template_path to specify values from data_path. + """ + if not data_path or len(data_path) < 3: + raise KeyError( + f"Missing or invalid required parameter: 'data_path' for template path '{template_path}'" + ) + device, group, data_key = data_path + realized_path: list[str | int] = [] + for part in template_path: + match part: + case PathPart.DEVICE: + realized_path.append(device) + case PathPart.INDEX | PathPart.KEY: + realized_path.append(group) + case PathPart.VALUE: + realized_path.append(data_key) + case _: + realized_path.append(part) + + return tuple(realized_path) + + +def preprocess_supported_values( + supported_devices: SupportedDeviceDescriptions, +) -> list[tuple[ScreenLogicDataPath, Any]]: + """Expand config dict into list of ScreenLogicDataPaths and settings.""" + processed: list[tuple[ScreenLogicDataPath, Any]] = [] + for device, device_groups in supported_devices.items(): + for group, group_values in device_groups.items(): + for value_key, value_params in group_values.items(): + value_data_path = (device, group, value_key) + processed.append((value_data_path, value_params)) + return processed + + +def iterate_expand_group_wildcard( + gateway: ScreenLogicGateway, + preprocessed_data: list[tuple[ScreenLogicDataPath, Any]], +) -> Generator[tuple[ScreenLogicDataPath, Any], None, None]: + """Iterate and expand any group wildcards to all available entries in gateway.""" + for data_path, value_params in preprocessed_data: + device, group, value_key = data_path + if group == "*": + for index in gateway.get_data(device): + yield ((device, index, value_key), value_params) + else: + yield (data_path, value_params) + + +def build_base_entity_description( + gateway: ScreenLogicGateway, + entity_key: str, + data_path: ScreenLogicDataPath, + value_data: dict, + value_params: SupportedValueParameters, +) -> dict: + """Build base entity description. + + Returns a dict of entity description key value pairs common to all entities. + """ + return { + "data_path": data_path, + "key": entity_key, + "entity_category": value_params.entity_category, + "entity_registry_enabled_default": value_params.enabled.test( + gateway, data_path + ), + "name": value_data.get(ATTR.NAME), + } + + +ENTITY_MIGRATIONS = { + "chem_alarm": { + "new_key": VALUE.ACTIVE_ALERT, + "old_name": "Chemistry Alarm", + "new_name": "Active Alert", + }, + "chem_calcium_harness": { + "new_key": VALUE.CALCIUM_HARNESS, + }, + "chem_current_orp": { + "new_key": VALUE.ORP_NOW, + "old_name": "Current ORP", + "new_name": "ORP Now", + }, + "chem_current_ph": { + "new_key": VALUE.PH_NOW, + "old_name": "Current pH", + "new_name": "pH Now", + }, + "chem_cya": { + "new_key": VALUE.CYA, + }, + "chem_orp_dosing_state": { + "new_key": VALUE.ORP_DOSING_STATE, + }, + "chem_orp_last_dose_time": { + "new_key": VALUE.ORP_LAST_DOSE_TIME, + }, + "chem_orp_last_dose_volume": { + "new_key": VALUE.ORP_LAST_DOSE_VOLUME, + }, + "chem_orp_setpoint": { + "new_key": VALUE.ORP_SETPOINT, + }, + "chem_orp_supply_level": { + "new_key": VALUE.ORP_SUPPLY_LEVEL, + }, + "chem_ph_dosing_state": { + "new_key": VALUE.PH_DOSING_STATE, + }, + "chem_ph_last_dose_time": { + "new_key": VALUE.PH_LAST_DOSE_TIME, + }, + "chem_ph_last_dose_volume": { + "new_key": VALUE.PH_LAST_DOSE_VOLUME, + }, + "chem_ph_probe_water_temp": { + "new_key": VALUE.PH_PROBE_WATER_TEMP, + }, + "chem_ph_setpoint": { + "new_key": VALUE.PH_SETPOINT, + }, + "chem_ph_supply_level": { + "new_key": VALUE.PH_SUPPLY_LEVEL, + }, + "chem_salt_tds_ppm": { + "new_key": VALUE.SALT_TDS_PPM, + }, + "chem_total_alkalinity": { + "new_key": VALUE.TOTAL_ALKALINITY, + }, + "currentGPM": { + "new_key": VALUE.GPM_NOW, + "old_name": "Current GPM", + "new_name": "GPM Now", + "device": DEVICE.PUMP, + }, + "currentRPM": { + "new_key": VALUE.RPM_NOW, + "old_name": "Current RPM", + "new_name": "RPM Now", + "device": DEVICE.PUMP, + }, + "currentWatts": { + "new_key": VALUE.WATTS_NOW, + "old_name": "Current Watts", + "new_name": "Watts Now", + "device": DEVICE.PUMP, + }, + "orp_alarm": { + "new_key": VALUE.ORP_LOW_ALARM, + "old_name": "ORP Alarm", + "new_name": "ORP LOW Alarm", + }, + "ph_alarm": { + "new_key": VALUE.PH_HIGH_ALARM, + "old_name": "pH Alarm", + "new_name": "pH HIGH Alarm", + }, + "scg_status": { + "new_key": VALUE.STATE, + "old_name": "SCG Status", + "new_name": "Chlorinator", + "device": DEVICE.SCG, + }, + "scg_level1": { + "new_key": VALUE.POOL_SETPOINT, + "old_name": "Pool SCG Level", + "new_name": "Pool Chlorinator Setpoint", + }, + "scg_level2": { + "new_key": VALUE.SPA_SETPOINT, + "old_name": "Spa SCG Level", + "new_name": "Spa Chlorinator Setpoint", + }, + "scg_salt_ppm": { + "new_key": VALUE.SALT_PPM, + "old_name": "SCG Salt", + "new_name": "Chlorinator Salt", + "device": DEVICE.SCG, + }, + "scg_super_chlor_timer": { + "new_key": VALUE.SUPER_CHLOR_TIMER, + "old_name": "SCG Super Chlorination Timer", + "new_name": "Super Chlorination Timer", + }, +} diff --git a/homeassistant/components/screenlogic/diagnostics.py b/homeassistant/components/screenlogic/diagnostics.py index ca949c4514c7f2..92e700239ff72d 100644 --- a/homeassistant/components/screenlogic/diagnostics.py +++ b/homeassistant/components/screenlogic/diagnostics.py @@ -5,8 +5,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import ScreenlogicDataUpdateCoordinator from .const import DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/screenlogic/entity.py b/homeassistant/components/screenlogic/entity.py index 955b73262a1647..a29aaa9125b480 100644 --- a/homeassistant/components/screenlogic/entity.py +++ b/homeassistant/components/screenlogic/entity.py @@ -1,52 +1,65 @@ """Base ScreenLogicEntity definitions.""" +from dataclasses import dataclass from datetime import datetime import logging from typing import Any from screenlogicpy import ScreenLogicGateway -from screenlogicpy.const import CODE, DATA as SL_DATA, EQUIPMENT, ON_OFF +from screenlogicpy.const.common import ON_OFF +from screenlogicpy.const.data import ATTR +from screenlogicpy.const.msg import CODE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import ScreenlogicDataUpdateCoordinator +from .const import ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) +@dataclass +class ScreenLogicEntityRequiredKeyMixin: + """Mixin for required ScreenLogic entity key.""" + + data_path: ScreenLogicDataPath + + +@dataclass +class ScreenLogicEntityDescription( + EntityDescription, ScreenLogicEntityRequiredKeyMixin +): + """Base class for a ScreenLogic entity description.""" + + class ScreenlogicEntity(CoordinatorEntity[ScreenlogicDataUpdateCoordinator]): """Base class for all ScreenLogic entities.""" + entity_description: ScreenLogicEntityDescription + _attr_has_entity_name = True + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - enabled: bool = True, + entity_description: ScreenLogicEntityDescription, ) -> None: """Initialize of the entity.""" super().__init__(coordinator) - self._data_key = data_key - self._attr_entity_registry_enabled_default = enabled - self._attr_unique_id = f"{self.mac}_{self._data_key}" - - controller_type = self.config_data["controller_type"] - hardware_type = self.config_data["hardware_type"] - try: - equipment_model = EQUIPMENT.CONTROLLER_HARDWARE[controller_type][ - hardware_type - ] - except KeyError: - equipment_model = f"Unknown Model C:{controller_type} H:{hardware_type}" + self.entity_description = entity_description + self._data_path = self.entity_description.data_path + self._data_key = self._data_path[-1] + self._attr_unique_id = f"{self.mac}_{self.entity_description.key}" mac = self.mac assert mac is not None self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, mac)}, manufacturer="Pentair", - model=equipment_model, - name=self.gateway_name, + model=self.gateway.controller_model, + name=self.gateway.name, sw_version=self.gateway.version, ) @@ -56,26 +69,11 @@ def mac(self) -> str | None: assert self.coordinator.config_entry is not None return self.coordinator.config_entry.unique_id - @property - def config_data(self) -> dict[str | int, Any]: - """Shortcut for config data.""" - return self.gateway_data[SL_DATA.KEY_CONFIG] - @property def gateway(self) -> ScreenLogicGateway: """Return the gateway.""" return self.coordinator.gateway - @property - def gateway_data(self) -> dict[str | int, Any]: - """Return the gateway data.""" - return self.gateway.get_data() - - @property - def gateway_name(self) -> str: - """Return the configured name of the gateway.""" - return self.gateway.name - async def _async_refresh(self) -> None: """Refresh the data from the gateway.""" await self.coordinator.async_refresh() @@ -87,20 +85,41 @@ async def _async_refresh_timed(self, now: datetime) -> None: """Refresh from a timed called.""" await self.coordinator.async_request_refresh() + @property + def entity_data(self) -> dict: + """Shortcut to the data for this entity.""" + if (data := self.gateway.get_data(*self._data_path)) is None: + raise KeyError(f"Data not found: {self._data_path}") + return data + + +@dataclass +class ScreenLogicPushEntityRequiredKeyMixin: + """Mixin for required key for ScreenLogic push entities.""" + + subscription_code: CODE + + +@dataclass +class ScreenLogicPushEntityDescription( + ScreenLogicEntityDescription, + ScreenLogicPushEntityRequiredKeyMixin, +): + """Base class for a ScreenLogic push entity description.""" + class ScreenLogicPushEntity(ScreenlogicEntity): """Base class for all ScreenLogic push entities.""" + entity_description: ScreenLogicPushEntityDescription + def __init__( self, coordinator: ScreenlogicDataUpdateCoordinator, - data_key: str, - message_code: CODE, - enabled: bool = True, + entity_description: ScreenLogicPushEntityDescription, ) -> None: - """Initialize the entity.""" - super().__init__(coordinator, data_key, enabled) - self._update_message_code = message_code + """Initialize of the entity.""" + super().__init__(coordinator, entity_description) self._last_update_success = True @callback @@ -114,7 +133,8 @@ async def async_added_to_hass(self) -> None: await super().async_added_to_hass() self.async_on_remove( await self.gateway.async_subscribe_client( - self._async_data_updated, self._update_message_code + self._async_data_updated, + self.entity_description.subscription_code, ) ) @@ -129,17 +149,10 @@ def _handle_coordinator_update(self) -> None: class ScreenLogicCircuitEntity(ScreenLogicPushEntity): """Base class for all ScreenLogic switch and light entities.""" - _attr_has_entity_name = True - - @property - def name(self) -> str: - """Get the name of the switch.""" - return self.circuit["name"] - @property def is_on(self) -> bool: """Get whether the switch is in on state.""" - return self.circuit["value"] == ON_OFF.ON + return self.entity_data[ATTR.VALUE] == ON_OFF.ON async def async_turn_on(self, **kwargs: Any) -> None: """Send the ON command.""" @@ -149,14 +162,9 @@ async def async_turn_off(self, **kwargs: Any) -> None: """Send the OFF command.""" await self._async_set_circuit(ON_OFF.OFF) - async def _async_set_circuit(self, circuit_value: int) -> None: - if not await self.gateway.async_set_circuit(self._data_key, circuit_value): + async def _async_set_circuit(self, state: ON_OFF) -> None: + if not await self.gateway.async_set_circuit(self._data_key, state.value): raise HomeAssistantError( - f"Failed to set_circuit {self._data_key} {circuit_value}" + f"Failed to set_circuit {self._data_key} {state.value}" ) - _LOGGER.debug("Turn %s %s", self._data_key, circuit_value) - - @property - def circuit(self) -> dict[str | int, Any]: - """Shortcut to access the circuit.""" - return self.gateway_data[SL_DATA.KEY_CIRCUITS][self._data_key] + _LOGGER.debug("Set circuit %s %s", self._data_key, state.value) diff --git a/homeassistant/components/screenlogic/light.py b/homeassistant/components/screenlogic/light.py index 3eae12178decdd..3875e34fbaabc2 100644 --- a/homeassistant/components/screenlogic/light.py +++ b/homeassistant/components/screenlogic/light.py @@ -1,16 +1,23 @@ """Support for a ScreenLogic light 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import CODE, DATA as SL_DATA, GENERIC_CIRCUIT_NAMES +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.light import ColorMode, LightEntity +from homeassistant.components.light import ( + ColorMode, + LightEntity, + LightEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -21,26 +28,45 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicLight] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] not in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicLight( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES, + ScreenLogicLightDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicLightDescription( + LightEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic light entity.""" class ScreenLogicLight(ScreenLogicCircuitEntity, LightEntity): """Class to represent a ScreenLogic Light.""" + entity_description: ScreenLogicLightDescription _attr_color_mode = ColorMode.ONOFF _attr_supported_color_modes = {ColorMode.ONOFF} diff --git a/homeassistant/components/screenlogic/manifest.json b/homeassistant/components/screenlogic/manifest.json index 5b8b83694274c8..9fc103dc8a8a7a 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.8.2"] + "requirements": ["screenlogicpy==0.9.0"] } diff --git a/homeassistant/components/screenlogic/number.py b/homeassistant/components/screenlogic/number.py index e0d5d0e6a671f0..22805ffc3c1470 100644 --- a/homeassistant/components/screenlogic/number.py +++ b/homeassistant/components/screenlogic/number.py @@ -1,25 +1,82 @@ """Support for a ScreenLogic number entity.""" +from collections.abc import Callable +from dataclasses import dataclass import logging -from screenlogicpy.const import BODY_TYPE, DATA as SL_DATA, EQUIPMENT, SCG +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE -from homeassistant.components.number import NumberEntity +from homeassistant.components.number import ( + DOMAIN, + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) from homeassistant.config_entries import ConfigEntry from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + PathPart, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, + realize_path_template, +) +from .entity import ScreenlogicEntity, ScreenLogicEntityDescription +from .util import cleanup_excluded_entity, generate_unique_id _LOGGER = logging.getLogger(__name__) PARALLEL_UPDATES = 1 -SUPPORTED_SCG_NUMBERS = ( - "scg_level1", - "scg_level2", + +@dataclass +class SupportedNumberValueParametersMixin: + """Mixin for supported predefined data for a ScreenLogic number entity.""" + + set_value_config: tuple[str, tuple[tuple[PathPart | str | int, ...], ...]] + device_class: NumberDeviceClass | None = None + + +@dataclass +class SupportedNumberValueParameters( + SupportedValueParameters, SupportedNumberValueParametersMixin +): + """Supported predefined data for a ScreenLogic number entity.""" + + +SET_SCG_CONFIG_FUNC_DATA = ( + "async_set_scg_config", + ( + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.POOL_SETPOINT), + (DEVICE.SCG, GROUP.CONFIGURATION, VALUE.SPA_SETPOINT), + ), +) + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.SCG: { + GROUP.CONFIGURATION: { + VALUE.POOL_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + VALUE.SPA_SETPOINT: SupportedNumberValueParameters( + entity_category=EntityCategory.CONFIG, + set_value_config=SET_SCG_CONFIG_FUNC_DATA, + ), + } + } + } ) @@ -29,66 +86,113 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicNumber] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - async_add_entities( - [ - ScreenLogicNumber(coordinator, scg_level) - for scg_level in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_level in SUPPORTED_SCG_NUMBERS - ] + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedNumberValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + set_value_str, set_value_params = value_params.set_value_config + set_value_func = getattr(gateway, set_value_str) + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": value_params.device_class, + "native_unit_of_measurement": get_ha_unit(value_data), + "native_max_value": value_data.get(ATTR.MAX_SETPOINT), + "native_min_value": value_data.get(ATTR.MIN_SETPOINT), + "native_step": value_data.get(ATTR.STEP), + "set_value": set_value_func, + "set_value_params": set_value_params, + } + + entities.append( + ScreenLogicNumber( + coordinator, + ScreenLogicNumberDescription(**entity_description_kwargs), + ) ) + async_add_entities(entities) + + +@dataclass +class ScreenLogicNumberRequiredMixin: + """Describes a required mixin for a ScreenLogic number entity.""" + + set_value: Callable[..., bool] + set_value_params: tuple[tuple[str | int, ...], ...] + + +@dataclass +class ScreenLogicNumberDescription( + NumberEntityDescription, + ScreenLogicEntityDescription, + ScreenLogicNumberRequiredMixin, +): + """Describes a ScreenLogic number entity.""" + class ScreenLogicNumber(ScreenlogicEntity, NumberEntity): - """Class to represent a ScreenLogic Number.""" + """Class to represent a ScreenLogic Number entity.""" - _attr_has_entity_name = True + entity_description: ScreenLogicNumberDescription - def __init__(self, coordinator, data_key, enabled=True): - """Initialize of the entity.""" - super().__init__(coordinator, data_key, enabled) - self._body_type = SUPPORTED_SCG_NUMBERS.index(self._data_key) - self._attr_native_max_value = SCG.LIMIT_FOR_BODY[self._body_type] - self._attr_name = self.sensor["name"] - self._attr_native_unit_of_measurement = self.sensor["unit"] - self._attr_entity_category = EntityCategory.CONFIG + def __init__( + self, + coordinator: ScreenlogicDataUpdateCoordinator, + entity_description: ScreenLogicNumberDescription, + ) -> None: + """Initialize a ScreenLogic number entity.""" + self._set_value_func = entity_description.set_value + self._set_value_params = entity_description.set_value_params + super().__init__(coordinator, entity_description) @property def native_value(self) -> float: """Return the current value.""" - return self.sensor["value"] + return self.entity_data[ATTR.VALUE] async def async_set_native_value(self, value: float) -> None: """Update the current value.""" - # Need to set both levels at the same time, so we gather - # both existing level values and override the one that changed. - levels = {} - for level in SUPPORTED_SCG_NUMBERS: - levels[level] = self.gateway_data[SL_DATA.KEY_SCG][level]["value"] - levels[self._data_key] = int(value) - - if await self.coordinator.gateway.async_set_scg_config( - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ): - _LOGGER.debug( - "Set SCG to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], + + # Current API requires certain values to be set at the same time. This + # gathers the existing values and updates the particular value being + # set by this entity. + args = {} + for data_path in self._set_value_params: + data_path = realize_path_template(data_path, self._data_path) + data_value = data_path[-1] + args[data_value] = self.coordinator.gateway.get_value( + *data_path, strict=True ) + + args[self._data_key] = value + + if self._set_value_func(*args.values()): + _LOGGER.debug("Set '%s' to %s", self._data_key, value) await self._async_refresh() else: - _LOGGER.warning( - "Failed to set_scg to %i, %i", - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.POOL]], - levels[SUPPORTED_SCG_NUMBERS[BODY_TYPE.SPA]], - ) - - @property - def sensor(self) -> dict: - """Shortcut to access the level sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + _LOGGER.debug("Failed to set '%s' to %s", self._data_key, value) diff --git a/homeassistant/components/screenlogic/sensor.py b/homeassistant/components/screenlogic/sensor.py index 3a9bc3cbee97cc..39805173961358 100644 --- a/homeassistant/components/screenlogic/sensor.py +++ b/homeassistant/components/screenlogic/sensor.py @@ -1,75 +1,147 @@ """Support for a ScreenLogic Sensor.""" -from typing import Any - -from screenlogicpy.const import ( - CHEM_DOSING_STATE, - CODE, - DATA as SL_DATA, - DEVICE_TYPE, - EQUIPMENT, - STATE_TYPE, - UNIT, -) +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from screenlogicpy.const.common import DEVICE_TYPE, STATE_TYPE +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE +from screenlogicpy.device_const.chemistry import DOSE_STATE +from screenlogicpy.device_const.pump import PUMP_TYPE +from screenlogicpy.device_const.system import EQUIPMENT_FLAG from homeassistant.components.sensor import ( + DOMAIN, SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ( - CONCENTRATION_PARTS_PER_MILLION, - PERCENTAGE, - REVOLUTIONS_PER_MINUTE, - EntityCategory, - UnitOfElectricPotential, - UnitOfPower, - UnitOfTemperature, - UnitOfTime, -) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN -from .entity import ScreenlogicEntity, ScreenLogicPushEntity - -SUPPORTED_BASIC_SENSORS = ( - "air_temperature", - "saturation", +from .const import DOMAIN as SL_DOMAIN, ScreenLogicDataPath +from .coordinator import ScreenlogicDataUpdateCoordinator +from .data import ( + DEVICE_INCLUSION_RULES, + DEVICE_SUBSCRIPTION, + PathPart, + ScreenLogicDataRule, + ScreenLogicEquipmentRule, + SupportedValueParameters, + build_base_entity_description, + get_ha_unit, + iterate_expand_group_wildcard, + preprocess_supported_values, ) - -SUPPORTED_BASIC_CHEM_SENSORS = ( - "orp", - "ph", +from .entity import ( + ScreenlogicEntity, + ScreenLogicEntityDescription, + ScreenLogicPushEntity, + ScreenLogicPushEntityDescription, ) +from .util import cleanup_excluded_entity, generate_unique_id -SUPPORTED_CHEM_SENSORS = ( - "calcium_harness", - "current_orp", - "current_ph", - "cya", - "orp_dosing_state", - "orp_last_dose_time", - "orp_last_dose_volume", - "orp_setpoint", - "orp_supply_level", - "ph_dosing_state", - "ph_last_dose_time", - "ph_last_dose_volume", - "ph_probe_water_temp", - "ph_setpoint", - "ph_supply_level", - "salt_tds_ppm", - "total_alkalinity", -) +_LOGGER = logging.getLogger(__name__) -SUPPORTED_SCG_SENSORS = ( - "scg_salt_ppm", - "scg_super_chlor_timer", -) -SUPPORTED_PUMP_SENSORS = ("currentWatts", "currentRPM", "currentGPM") +@dataclass +class SupportedSensorValueParameters(SupportedValueParameters): + """Supported predefined data for a ScreenLogic sensor entity.""" + + device_class: SensorDeviceClass | None = None + value_modification: Callable[[int], int | str] | None = lambda val: val + + +SUPPORTED_DATA: list[ + tuple[ScreenLogicDataPath, SupportedValueParameters] +] = preprocess_supported_values( + { + DEVICE.CONTROLLER: { + GROUP.SENSOR: { + VALUE.AIR_TEMPERATURE: SupportedSensorValueParameters( + device_class=SensorDeviceClass.TEMPERATURE, entity_category=None + ), + VALUE.ORP: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + VALUE.PH: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + ) + ), + }, + }, + DEVICE.PUMP: { + "*": { + VALUE.WATTS_NOW: SupportedSensorValueParameters(), + VALUE.GPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VS, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + VALUE.RPM_NOW: SupportedSensorValueParameters( + enabled=ScreenLogicDataRule( + lambda pump_data: pump_data[VALUE.TYPE] + != PUMP_TYPE.INTELLIFLO_VF, + (PathPart.DEVICE, PathPart.INDEX), + ) + ), + }, + }, + DEVICE.INTELLICHEM: { + GROUP.SENSOR: { + VALUE.ORP_NOW: SupportedSensorValueParameters(), + VALUE.ORP_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.PH_NOW: SupportedSensorValueParameters(), + VALUE.PH_PROBE_WATER_TEMP: SupportedSensorValueParameters(), + VALUE.PH_SUPPLY_LEVEL: SupportedSensorValueParameters( + value_modification=lambda val: val - 1 + ), + VALUE.SATURATION: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.CALCIUM_HARNESS: SupportedSensorValueParameters(), + VALUE.CYA: SupportedSensorValueParameters(), + VALUE.ORP_SETPOINT: SupportedSensorValueParameters(), + VALUE.PH_SETPOINT: SupportedSensorValueParameters(), + VALUE.SALT_TDS_PPM: SupportedSensorValueParameters( + included=ScreenLogicEquipmentRule( + lambda flags: EQUIPMENT_FLAG.INTELLICHEM in flags + and EQUIPMENT_FLAG.CHLORINATOR not in flags, + ) + ), + VALUE.TOTAL_ALKALINITY: SupportedSensorValueParameters(), + }, + GROUP.DOSE_STATUS: { + VALUE.ORP_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.ORP_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.ORP_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + VALUE.PH_DOSING_STATE: SupportedSensorValueParameters( + value_modification=lambda val: DOSE_STATE(val).title, + ), + VALUE.PH_LAST_DOSE_TIME: SupportedSensorValueParameters(), + VALUE.PH_LAST_DOSE_VOLUME: SupportedSensorValueParameters(), + }, + }, + DEVICE.SCG: { + GROUP.SENSOR: { + VALUE.SALT_PPM: SupportedSensorValueParameters(), + }, + GROUP.CONFIGURATION: { + VALUE.SUPER_CHLOR_TIMER: SupportedSensorValueParameters(), + }, + }, + } +) SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS = { DEVICE_TYPE.DURATION: SensorDeviceClass.DURATION, @@ -85,18 +157,6 @@ STATE_TYPE.TOTAL_INCREASING: SensorStateClass.TOTAL_INCREASING, } -SL_UNIT_TO_HA_UNIT = { - UNIT.CELSIUS: UnitOfTemperature.CELSIUS, - UNIT.FAHRENHEIT: UnitOfTemperature.FAHRENHEIT, - UNIT.MILLIVOLT: UnitOfElectricPotential.MILLIVOLT, - UNIT.WATT: UnitOfPower.WATT, - UNIT.HOUR: UnitOfTime.HOURS, - UNIT.SECOND: UnitOfTime.SECONDS, - UNIT.REVOLUTIONS_PER_MINUTE: REVOLUTIONS_PER_MINUTE, - UNIT.PARTS_PER_MILLION: CONCENTRATION_PARTS_PER_MILLION, - UNIT.PERCENT: PERCENTAGE, -} - async def async_setup_entry( hass: HomeAssistant, @@ -104,171 +164,110 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - entities: list[ScreenLogicSensorEntity] = [] - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSensor] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - equipment_flags = coordinator.gateway_data[SL_DATA.KEY_CONFIG]["equipment_flags"] - - # Generic push sensors - for sensor_name in coordinator.gateway_data[SL_DATA.KEY_SENSORS]: - if sensor_name in SUPPORTED_BASIC_SENSORS: - entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) - ) + gateway = coordinator.gateway + data_path: ScreenLogicDataPath + value_params: SupportedSensorValueParameters + for data_path, value_params in iterate_expand_group_wildcard( + gateway, SUPPORTED_DATA + ): + entity_key = generate_unique_id(*data_path) + + device = data_path[0] + + if not (DEVICE_INCLUSION_RULES.get(device) or value_params.included).test( + gateway, data_path + ): + cleanup_excluded_entity(coordinator, DOMAIN, entity_key) + continue + + try: + value_data = gateway.get_data(*data_path, strict=True) + except KeyError: + _LOGGER.debug("Failed to find %s", data_path) + continue + + entity_description_kwargs = { + **build_base_entity_description( + gateway, entity_key, data_path, value_data, value_params + ), + "device_class": SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get( + value_data.get(ATTR.DEVICE_TYPE) + ), + "native_unit_of_measurement": get_ha_unit(value_data), + "options": value_data.get(ATTR.ENUM_OPTIONS), + "state_class": SL_STATE_TYPE_TO_HA_STATE_CLASS.get( + value_data.get(ATTR.STATE_TYPE) + ), + "value_mod": value_params.value_modification, + } - # While these values exist in the chemistry data, their last value doesn't - # persist there when the pump is off/there is no flow. Pulling them from - # the basic sensors keeps the 'last' value and is better for graphs. if ( - equipment_flags & EQUIPMENT.FLAG_INTELLICHEM - and sensor_name in SUPPORTED_BASIC_CHEM_SENSORS - ): + sub_code := ( + value_params.subscription_code or DEVICE_SUBSCRIPTION.get(device) + ) + ) is not None: entities.append( - ScreenLogicStatusSensor(coordinator, sensor_name, CODE.STATUS_CHANGED) + ScreenLogicPushSensor( + coordinator, + ScreenLogicPushSensorDescription( + subscription_code=sub_code, + **entity_description_kwargs, + ), + ) ) - - # Pump sensors - for pump_num, pump_data in coordinator.gateway_data[SL_DATA.KEY_PUMPS].items(): - if pump_data["data"] != 0 and "currentWatts" in pump_data: - for pump_key in pump_data: - enabled = True - # Assumptions for Intelliflow VF - if pump_data["pumpType"] == 1 and pump_key == "currentRPM": - enabled = False - # Assumptions for Intelliflow VS - if pump_data["pumpType"] == 2 and pump_key == "currentGPM": - enabled = False - if pump_key in SUPPORTED_PUMP_SENSORS: - entities.append( - ScreenLogicPumpSensor(coordinator, pump_num, pump_key, enabled) - ) - - # IntelliChem sensors - if equipment_flags & EQUIPMENT.FLAG_INTELLICHEM: - for chem_sensor_name in coordinator.gateway_data[SL_DATA.KEY_CHEMISTRY]: - enabled = True - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - if chem_sensor_name in ("salt_tds_ppm",): - enabled = False - if chem_sensor_name in SUPPORTED_CHEM_SENSORS: - entities.append( - ScreenLogicChemistrySensor( - coordinator, chem_sensor_name, CODE.CHEMISTRY_CHANGED, enabled - ) + else: + entities.append( + ScreenLogicSensor( + coordinator, + ScreenLogicSensorDescription( + **entity_description_kwargs, + ), ) - - # SCG sensors - if equipment_flags & EQUIPMENT.FLAG_CHLORINATOR: - entities.extend( - [ - ScreenLogicSCGSensor(coordinator, scg_sensor) - for scg_sensor in coordinator.gateway_data[SL_DATA.KEY_SCG] - if scg_sensor in SUPPORTED_SCG_SENSORS - ] - ) + ) async_add_entities(entities) -class ScreenLogicSensorEntity(ScreenlogicEntity, SensorEntity): - """Base class for all ScreenLogic sensor entities.""" +@dataclass +class ScreenLogicSensorMixin: + """Mixin for SecreenLogic sensor entity.""" - _attr_has_entity_name = True + value_mod: Callable[[int | str], int | str] | None = None - @property - def name(self) -> str | None: - """Name of the sensor.""" - return self.sensor["name"] - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement.""" - sl_unit = self.sensor.get("unit") - return SL_UNIT_TO_HA_UNIT.get(sl_unit, sl_unit) +@dataclass +class ScreenLogicSensorDescription( + ScreenLogicSensorMixin, SensorEntityDescription, ScreenLogicEntityDescription +): + """Describes a ScreenLogic sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Device class of the sensor.""" - device_type = self.sensor.get("device_type") - return SL_DEVICE_TYPE_TO_HA_DEVICE_CLASS.get(device_type) - @property - def entity_category(self) -> EntityCategory | None: - """Entity Category of the sensor.""" - return ( - None if self._data_key == "air_temperature" else EntityCategory.DIAGNOSTIC - ) +class ScreenLogicSensor(ScreenlogicEntity, SensorEntity): + """Representation of a ScreenLogic sensor entity.""" - @property - def state_class(self) -> SensorStateClass | None: - """Return the state class of the sensor.""" - state_type = self.sensor.get("state_type") - if self._data_key == "scg_super_chlor_timer": - return None - return SL_STATE_TYPE_TO_HA_STATE_CLASS.get(state_type) - - @property - def options(self) -> list[str] | None: - """Return a set of possible options.""" - return self.sensor.get("enum_options") + entity_description: ScreenLogicSensorDescription + _attr_has_entity_name = True @property def native_value(self) -> str | int | float: """State of the sensor.""" - return self.sensor["value"] - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the sensor data.""" - return self.gateway_data[SL_DATA.KEY_SENSORS][self._data_key] - + val = self.entity_data[ATTR.VALUE] + value_mod = self.entity_description.value_mod + return value_mod(val) if value_mod else val -class ScreenLogicStatusSensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a basic ScreenLogic sensor entity.""" +@dataclass +class ScreenLogicPushSensorDescription( + ScreenLogicSensorDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic push sensor.""" -class ScreenLogicPumpSensor(ScreenLogicSensorEntity): - """Representation of a ScreenLogic pump sensor entity.""" - def __init__(self, coordinator, pump, key, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"{key}_{pump}", enabled) - self._pump_id = pump - self._key = key +class ScreenLogicPushSensor(ScreenLogicSensor, ScreenLogicPushEntity): + """Representation of a ScreenLogic push sensor entity.""" - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_PUMPS][self._pump_id][self._key] - - -class ScreenLogicChemistrySensor(ScreenLogicSensorEntity, ScreenLogicPushEntity): - """Representation of a ScreenLogic IntelliChem sensor entity.""" - - def __init__(self, coordinator, key, message_code, enabled=True): - """Initialize of the pump sensor.""" - super().__init__(coordinator, f"chem_{key}", message_code, enabled) - self._key = key - - @property - def native_value(self) -> str | int | float: - """State of the sensor.""" - value = self.sensor["value"] - if "dosing_state" in self._key: - return CHEM_DOSING_STATE.NAME_FOR_NUM[value] - return (value - 1) if "supply" in self._data_key else value - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_CHEMISTRY][self._key] - - -class ScreenLogicSCGSensor(ScreenLogicSensorEntity): - """Representation of ScreenLogic SCG sensor entity.""" - - @property - def sensor(self) -> dict[str | int, Any]: - """Shortcut to access the pump sensor data.""" - return self.gateway_data[SL_DATA.KEY_SCG][self._data_key] + entity_description: ScreenLogicPushSensorDescription diff --git a/homeassistant/components/screenlogic/switch.py b/homeassistant/components/screenlogic/switch.py index 96bced70867f2c..247ec4f2f03419 100644 --- a/homeassistant/components/screenlogic/switch.py +++ b/homeassistant/components/screenlogic/switch.py @@ -1,21 +1,19 @@ """Support for a ScreenLogic 'circuit' switch.""" +from dataclasses import dataclass import logging -from screenlogicpy.const import ( - CODE, - DATA as SL_DATA, - GENERIC_CIRCUIT_NAMES, - INTERFACE_GROUP, -) +from screenlogicpy.const.data import ATTR, DEVICE +from screenlogicpy.const.msg import CODE +from screenlogicpy.device_const.circuit import GENERIC_CIRCUIT_NAMES, INTERFACE -from homeassistant.components.switch import SwitchEntity +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import ScreenlogicDataUpdateCoordinator -from .const import DOMAIN, LIGHT_CIRCUIT_FUNCTIONS -from .entity import ScreenLogicCircuitEntity +from .const import DOMAIN as SL_DOMAIN, LIGHT_CIRCUIT_FUNCTIONS +from .coordinator import ScreenlogicDataUpdateCoordinator +from .entity import ScreenLogicCircuitEntity, ScreenLogicPushEntityDescription _LOGGER = logging.getLogger(__name__) @@ -26,24 +24,43 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up entry.""" - coordinator: ScreenlogicDataUpdateCoordinator = hass.data[DOMAIN][ + entities: list[ScreenLogicSwitch] = [] + coordinator: ScreenlogicDataUpdateCoordinator = hass.data[SL_DOMAIN][ config_entry.entry_id ] - circuits = coordinator.gateway_data[SL_DATA.KEY_CIRCUITS] - async_add_entities( - [ + gateway = coordinator.gateway + for circuit_index, circuit_data in gateway.get_data(DEVICE.CIRCUIT).items(): + if circuit_data[ATTR.FUNCTION] in LIGHT_CIRCUIT_FUNCTIONS: + continue + circuit_name = circuit_data[ATTR.NAME] + circuit_interface = INTERFACE(circuit_data[ATTR.INTERFACE]) + entities.append( ScreenLogicSwitch( coordinator, - circuit_num, - CODE.STATUS_CHANGED, - circuit["name"] not in GENERIC_CIRCUIT_NAMES - and circuit["interface"] != INTERFACE_GROUP.DONT_SHOW, + ScreenLogicSwitchDescription( + subscription_code=CODE.STATUS_CHANGED, + data_path=(DEVICE.CIRCUIT, circuit_index), + key=circuit_index, + name=circuit_name, + entity_registry_enabled_default=( + circuit_name not in GENERIC_CIRCUIT_NAMES + and circuit_interface != INTERFACE.DONT_SHOW + ), + ), ) - for circuit_num, circuit in circuits.items() - if circuit["function"] not in LIGHT_CIRCUIT_FUNCTIONS - ] - ) + ) + + async_add_entities(entities) + + +@dataclass +class ScreenLogicSwitchDescription( + SwitchEntityDescription, ScreenLogicPushEntityDescription +): + """Describes a ScreenLogic switch entity.""" class ScreenLogicSwitch(ScreenLogicCircuitEntity, SwitchEntity): """Class to represent a ScreenLogic Switch.""" + + entity_description: ScreenLogicSwitchDescription diff --git a/homeassistant/components/screenlogic/util.py b/homeassistant/components/screenlogic/util.py new file mode 100644 index 00000000000000..c8d9d5f0f771f9 --- /dev/null +++ b/homeassistant/components/screenlogic/util.py @@ -0,0 +1,40 @@ +"""Utility functions for the ScreenLogic integration.""" +import logging + +from screenlogicpy.const.data import SHARED_VALUES + +from homeassistant.helpers import entity_registry as er + +from .const import DOMAIN as SL_DOMAIN +from .coordinator import ScreenlogicDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +def generate_unique_id( + device: str | int, group: str | int | None, data_key: str | int +) -> str: + """Generate new unique_id for a screenlogic entity from specified parameters.""" + if data_key in SHARED_VALUES and device is not None: + if group is not None and (isinstance(group, int) or group.isdigit()): + return f"{device}_{group}_{data_key}" + return f"{device}_{data_key}" + return str(data_key) + + +def cleanup_excluded_entity( + coordinator: ScreenlogicDataUpdateCoordinator, + platform_domain: str, + entity_key: str, +) -> None: + """Remove excluded entity if it exists.""" + assert coordinator.config_entry + entity_registry = er.async_get(coordinator.hass) + unique_id = f"{coordinator.config_entry.unique_id}_{entity_key}" + if entity_id := entity_registry.async_get_entity_id( + platform_domain, SL_DOMAIN, unique_id + ): + _LOGGER.debug( + "Removing existing entity '%s' per data inclusion rule", entity_id + ) + entity_registry.async_remove(entity_id) diff --git a/requirements_all.txt b/requirements_all.txt index 5697dc28c946ac..371765cb1d19e0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2358,7 +2358,7 @@ satel-integra==0.3.7 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.scsgate scsgate==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3416452cb9b62b..0e01d005d9814a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1730,7 +1730,7 @@ samsungtvws[async,encrypted]==2.6.0 scapy==2.5.0 # homeassistant.components.screenlogic -screenlogicpy==0.8.2 +screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 diff --git a/tests/components/screenlogic/__init__.py b/tests/components/screenlogic/__init__.py index ad2b82960f0662..48362722312f01 100644 --- a/tests/components/screenlogic/__init__.py +++ b/tests/components/screenlogic/__init__.py @@ -1 +1,67 @@ """Tests for the Screenlogic integration.""" +from collections.abc import Callable +import logging + +from tests.common import load_json_object_fixture + +MOCK_ADAPTER_NAME = "Pentair DD-EE-FF" +MOCK_ADAPTER_MAC = "aa:bb:cc:dd:ee:ff" +MOCK_ADAPTER_IP = "127.0.0.1" +MOCK_ADAPTER_PORT = 80 + +_LOGGER = logging.getLogger(__name__) + + +GATEWAY_DISCOVERY_IMPORT_PATH = "homeassistant.components.screenlogic.coordinator.async_discover_gateways_by_unique_id" + + +def num_key_string_to_int(data: dict) -> None: + """Convert all string number dict keys to integer. + + This needed for screenlogicpy's data dict format. + """ + rpl = [] + for key, value in data.items(): + if isinstance(value, dict): + num_key_string_to_int(value) + if isinstance(key, str) and key.isnumeric(): + rpl.append(key) + for k in rpl: + data[int(k)] = data.pop(k) + + return data + + +DATA_FULL_CHEM = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_full_chem.json") +) +DATA_MIN_MIGRATION = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_migration.json") +) +DATA_MIN_ENTITY_CLEANUP = num_key_string_to_int( + load_json_object_fixture("screenlogic/data_min_entity_cleanup.json") +) + + +async def stub_async_connect( + data, + self, + ip=None, + port=None, + gtype=None, + gsubtype=None, + name=MOCK_ADAPTER_NAME, + connection_closed_callback: Callable = None, +) -> bool: + """Initialize minimum attributes needed for tests.""" + self._ip = ip + self._port = port + self._type = gtype + self._subtype = gsubtype + self._name = name + self._custom_connection_closed_callback = connection_closed_callback + self._mac = MOCK_ADAPTER_MAC + self._data = data + _LOGGER.debug("Gateway mock connected") + + return True diff --git a/tests/components/screenlogic/conftest.py b/tests/components/screenlogic/conftest.py new file mode 100644 index 00000000000000..3795df3dddc858 --- /dev/null +++ b/tests/components/screenlogic/conftest.py @@ -0,0 +1,27 @@ +"""Setup fixtures for ScreenLogic integration tests.""" +import pytest + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.const import CONF_IP_ADDRESS, CONF_PORT, CONF_SCAN_INTERVAL + +from . import MOCK_ADAPTER_IP, MOCK_ADAPTER_MAC, MOCK_ADAPTER_NAME, MOCK_ADAPTER_PORT + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return a mocked config entry.""" + return MockConfigEntry( + title=MOCK_ADAPTER_NAME, + domain=DOMAIN, + data={ + CONF_IP_ADDRESS: MOCK_ADAPTER_IP, + CONF_PORT: MOCK_ADAPTER_PORT, + }, + options={ + CONF_SCAN_INTERVAL: 30, + }, + unique_id=MOCK_ADAPTER_MAC, + entry_id="screenlogictest", + ) diff --git a/tests/components/screenlogic/fixtures/data_full_chem.json b/tests/components/screenlogic/fixtures/data_full_chem.json new file mode 100644 index 00000000000000..6c9ece22fcfc11 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_full_chem.json @@ -0,0 +1,880 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0, + "controller_data": 0, + "generic_circuit_name": "Water Features", + "circuit_count": 11, + "color_count": 8, + "color": [ + { + "name": "White", + "value": [255, 255, 255] + }, + { + "name": "Light Green", + "value": [160, 255, 160] + }, + { + "name": "Green", + "value": [0, 255, 80] + }, + { + "name": "Cyan", + "value": [0, 255, 200] + }, + { + "name": "Blue", + "value": [100, 140, 255] + }, + { + "name": "Lavender", + "value": [230, 130, 255] + }, + { + "name": "Magenta", + "value": [255, 0, 128] + }, + { + "name": "Light Magenta", + "value": [255, 180, 210] + } + ], + "interface_tab_flags": 127, + "show_alarms": 0, + "remotes": 0, + "unknown_at_offset_09": 0, + "unknown_at_offset_10": 0, + "unknown_at_offset_11": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 98360, + "list": [ + "INTELLIBRITE", + "INTELLIFLO_0", + "INTELLIFLO_1", + "INTELLICHEM", + "HYBRID_HEATER" + ] + }, + "sensor": { + "state": { + "name": "Controller State", + "value": 1, + "device_type": "enum", + "enum_options": ["Unknown", "Ready", "Sync", "Service"] + }, + "freeze_mode": { + "name": "Freeze Mode", + "value": 0 + }, + "pool_delay": { + "name": "Pool Delay", + "value": 0 + }, + "spa_delay": { + "name": "Spa Delay", + "value": 0 + }, + "cleaner_delay": { + "name": "Cleaner Delay", + "value": 0 + }, + "air_temperature": { + "name": "Air Temperature", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "ph": { + "name": "pH", + "value": 7.61, + "unit": "pH", + "state_type": "measurement" + }, + "orp": { + "name": "ORP", + "value": 728, + "unit": "mV", + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "salt_ppm": { + "name": "Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": { + "500": { + "circuit_id": 500, + "name": "Spa", + "configuration": { + "name_index": 71, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_62": 0, + "unknown_at_offset_63": 0, + "delay": 0 + }, + "function": 1, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 1, + "value": 0 + }, + "501": { + "circuit_id": 501, + "name": "Waterfall", + "configuration": { + "name_index": 85, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_94": 0, + "unknown_at_offset_95": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 2, + "value": 0 + }, + "502": { + "circuit_id": 502, + "name": "Pool Light", + "configuration": { + "name_index": 62, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_126": 0, + "unknown_at_offset_127": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 2, + "color_position": 0, + "color_stagger": 2 + }, + "device_id": 3, + "value": 0 + }, + "503": { + "circuit_id": 503, + "name": "Spa Light", + "configuration": { + "name_index": 73, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_158": 0, + "unknown_at_offset_159": 0, + "delay": 0 + }, + "function": 16, + "interface": 3, + "color": { + "color_set": 6, + "color_position": 1, + "color_stagger": 10 + }, + "device_id": 4, + "value": 0 + }, + "504": { + "circuit_id": 504, + "name": "Cleaner", + "configuration": { + "name_index": 21, + "flags": 0, + "default_runtime": 240, + "unknown_at_offset_186": 0, + "unknown_at_offset_187": 0, + "delay": 0 + }, + "function": 5, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 5, + "value": 0 + }, + "505": { + "circuit_id": 505, + "name": "Pool Low", + "configuration": { + "name_index": 63, + "flags": 1, + "default_runtime": 720, + "unknown_at_offset_214": 0, + "unknown_at_offset_215": 0, + "delay": 0 + }, + "function": 2, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 6, + "value": 0 + }, + "506": { + "circuit_id": 506, + "name": "Yard Light", + "configuration": { + "name_index": 91, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_246": 0, + "unknown_at_offset_247": 0, + "delay": 0 + }, + "function": 7, + "interface": 4, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 7, + "value": 0 + }, + "507": { + "circuit_id": 507, + "name": "Cameras", + "configuration": { + "name_index": 101, + "flags": 0, + "default_runtime": 1620, + "unknown_at_offset_274": 0, + "unknown_at_offset_275": 0, + "delay": 0 + }, + "function": 0, + "interface": 2, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 8, + "value": 1 + }, + "508": { + "circuit_id": 508, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_306": 0, + "unknown_at_offset_307": 0, + "delay": 0 + }, + "function": 0, + "interface": 0, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 9, + "value": 0 + }, + "510": { + "circuit_id": 510, + "name": "Spillway", + "configuration": { + "name_index": 78, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_334": 0, + "unknown_at_offset_335": 0, + "delay": 0 + }, + "function": 14, + "interface": 1, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 11, + "value": 0 + }, + "511": { + "circuit_id": 511, + "name": "Pool High", + "configuration": { + "name_index": 61, + "flags": 0, + "default_runtime": 720, + "unknown_at_offset_366": 0, + "unknown_at_offset_367": 0, + "delay": 0 + }, + "function": 0, + "interface": 5, + "color": { + "color_set": 0, + "color_position": 0, + "color_stagger": 0 + }, + "device_id": 12, + "value": 0 + } + }, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Pool Low Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 6, + "setpoint": 63, + "is_rpm": 0 + }, + "1": { + "device_id": 9, + "setpoint": 72, + "is_rpm": 0 + }, + "2": { + "device_id": 1, + "setpoint": 3450, + "is_rpm": 1 + }, + "3": { + "device_id": 130, + "setpoint": 75, + "is_rpm": 0 + }, + "4": { + "device_id": 12, + "setpoint": 72, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "1": { + "data": 66, + "type": 3, + "state": { + "name": "Waterfall Pump", + "value": 0 + }, + "watts_now": { + "name": "Waterfall Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Waterfall Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + }, + "unknown_at_offset_16": 0, + "gpm_now": { + "name": "Waterfall Pump GPM Now", + "value": 0, + "unit": "gpm", + "state_type": "measurement" + }, + "unknown_at_offset_24": 255, + "preset": { + "0": { + "device_id": 2, + "setpoint": 2700, + "is_rpm": 1 + }, + "1": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "2": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "3": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "4": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "5": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "6": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + }, + "7": { + "device_id": 0, + "setpoint": 30, + "is_rpm": 0 + } + } + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": { + "0": { + "body_type": 0, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Pool", + "last_temperature": { + "name": "Last Pool Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Pool Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Pool Heat Set Point", + "value": 83, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Pool Cool Set Point", + "value": 100, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Pool Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + }, + "1": { + "body_type": 1, + "min_setpoint": 40, + "max_setpoint": 104, + "name": "Spa", + "last_temperature": { + "name": "Last Spa Temperature", + "value": 84, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + }, + "heat_state": { + "name": "Spa Heat", + "value": 0, + "device_type": "enum", + "enum_options": ["Off", "Solar", "Heater", "Both"] + }, + "heat_setpoint": { + "name": "Spa Heat Set Point", + "value": 94, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "cool_setpoint": { + "name": "Spa Cool Set Point", + "value": 69, + "unit": "\u00b0F", + "device_type": "temperature" + }, + "heat_mode": { + "name": "Spa Heat Mode", + "value": 0, + "device_type": "enum", + "enum_options": [ + "Off", + "Solar", + "Solar Preferred", + "Heater", + "Don't Change" + ] + } + } + }, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + }, + "ph_supply_level": { + "name": "pH Supply Level", + "value": 2, + "state_type": "measurement" + }, + "orp_supply_level": { + "name": "ORP Supply Level", + "value": 3, + "state_type": "measurement" + }, + "saturation": { + "name": "Saturation Index", + "value": 0.06, + "unit": "lsi", + "state_type": "measurement" + }, + "ph_probe_water_temp": { + "name": "pH Probe Water Temperature", + "value": 81, + "unit": "\u00b0F", + "device_type": "temperature", + "state_type": "measurement" + } + }, + "configuration": { + "ph_setpoint": { + "name": "pH Setpoint", + "value": 7.6, + "unit": "pH" + }, + "orp_setpoint": { + "name": "ORP Setpoint", + "value": 720, + "unit": "mV" + }, + "calcium_harness": { + "name": "Calcium Hardness", + "value": 800, + "unit": "ppm" + }, + "cya": { + "name": "Cyanuric Acid", + "value": 45, + "unit": "ppm" + }, + "total_alkalinity": { + "name": "Total Alkalinity", + "value": 45, + "unit": "ppm" + }, + "salt_tds_ppm": { + "name": "Salt/TDS", + "value": 1000, + "unit": "ppm" + }, + "probe_is_celsius": 0, + "flags": 32 + }, + "dose_status": { + "ph_last_dose_time": { + "name": "Last pH Dose Time", + "value": 5, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "orp_last_dose_time": { + "name": "Last ORP Dose Time", + "value": 4, + "unit": "sec", + "device_type": "duration", + "state_type": "total_increasing" + }, + "ph_last_dose_volume": { + "name": "Last pH Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "orp_last_dose_volume": { + "name": "Last ORP Dose Volume", + "value": 8, + "unit": "mL", + "device_type": "volume", + "state_type": "total_increasing" + }, + "flags": 149, + "ph_dosing_state": { + "name": "pH Dosing State", + "value": 1, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + }, + "orp_dosing_state": { + "name": "ORP Dosing State", + "value": 2, + "device_type": "enum", + "enum_options": ["Dosing", "Mixing", "Monitoring"] + } + }, + "alarm": { + "flags": 1, + "flow_alarm": { + "name": "Flow Alarm", + "value": 1, + "device_type": "alarm" + }, + "ph_high_alarm": { + "name": "pH HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_low_alarm": { + "name": "pH LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_high_alarm": { + "name": "ORP HIGH Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_low_alarm": { + "name": "ORP LOW Alarm", + "value": 0, + "device_type": "alarm" + }, + "ph_supply_alarm": { + "name": "pH Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "orp_supply_alarm": { + "name": "ORP Supply Alarm", + "value": 0, + "device_type": "alarm" + }, + "probe_fault_alarm": { + "name": "Probe Fault", + "value": 0, + "device_type": "alarm" + } + }, + "alert": { + "flags": 0, + "ph_lockout": { + "name": "pH Lockout", + "value": 0 + }, + "ph_limit": { + "name": "pH Dose Limit Reached", + "value": 0 + }, + "orp_limit": { + "name": "ORP Dose Limit Reached", + "value": 0 + } + }, + "firmware": { + "name": "IntelliChem Firmware", + "value": "1.060" + }, + "water_balance": { + "flags": 0, + "corrosive": { + "name": "SI Corrosive", + "value": 0, + "device_type": "alarm" + }, + "scaling": { + "name": "SI Scaling", + "value": 0, + "device_type": "alarm" + } + }, + "unknown_at_offset_44": 0, + "unknown_at_offset_45": 0, + "unknown_at_offset_46": 0 + }, + "scg": { + "scg_present": 0, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json new file mode 100644 index 00000000000000..40f7dbe4ad50b5 --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_entity_cleanup.json @@ -0,0 +1,38 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { "min_setpoint": 40, "max_setpoint": 104 }, + "1": { "min_setpoint": 40, "max_setpoint": 104 } + }, + "is_celsius": { "name": "Is Celsius", "value": 0 }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { "name": "Model", "value": "EasyTouch2 8" }, + "equipment": { + "flags": 24 + } + }, + "circuit": {}, + "pump": { + "0": { "data": 0 }, + "1": { "data": 0 }, + "2": { "data": 0 }, + "3": { "data": 0 }, + "4": { "data": 0 }, + "5": { "data": 0 }, + "6": { "data": 0 }, + "7": { "data": 0 } + }, + "body": {}, + "intellichem": {}, + "scg": {} +} diff --git a/tests/components/screenlogic/fixtures/data_min_migration.json b/tests/components/screenlogic/fixtures/data_min_migration.json new file mode 100644 index 00000000000000..335c98db0ae7bb --- /dev/null +++ b/tests/components/screenlogic/fixtures/data_min_migration.json @@ -0,0 +1,151 @@ +{ + "adapter": { + "firmware": { + "name": "Protocol Adapter Firmware", + "value": "POOL: 5.2 Build 736.0 Rel" + } + }, + "controller": { + "controller_id": 100, + "configuration": { + "body_type": { + "0": { + "min_setpoint": 40, + "max_setpoint": 104 + }, + "1": { + "min_setpoint": 40, + "max_setpoint": 104 + } + }, + "is_celsius": { + "name": "Is Celsius", + "value": 0 + }, + "controller_type": 13, + "hardware_type": 0 + }, + "model": { + "name": "Model", + "value": "EasyTouch2 8" + }, + "equipment": { + "flags": 32796 + }, + "sensor": { + "active_alert": { + "name": "Active Alert", + "value": 0, + "device_type": "alarm" + } + } + }, + "circuit": {}, + "pump": { + "0": { + "data": 70, + "type": 3, + "state": { + "name": "Pool Low Pump", + "value": 0 + }, + "watts_now": { + "name": "Pool Low Pump Watts Now", + "value": 0, + "unit": "W", + "device_type": "power", + "state_type": "measurement" + }, + "rpm_now": { + "name": "Pool Low Pump RPM Now", + "value": 0, + "unit": "rpm", + "state_type": "measurement" + } + }, + "1": { + "data": 0 + }, + "2": { + "data": 0 + }, + "3": { + "data": 0 + }, + "4": { + "data": 0 + }, + "5": { + "data": 0 + }, + "6": { + "data": 0 + }, + "7": { + "data": 0 + } + }, + "body": {}, + "intellichem": { + "unknown_at_offset_00": 42, + "unknown_at_offset_04": 0, + "sensor": { + "ph_now": { + "name": "pH Now", + "value": 0.0, + "unit": "pH", + "state_type": "measurement" + }, + "orp_now": { + "name": "ORP Now", + "value": 0, + "unit": "mV", + "state_type": "measurement" + } + } + }, + "scg": { + "scg_present": 1, + "sensor": { + "state": { + "name": "Chlorinator", + "value": 0 + }, + "salt_ppm": { + "name": "Chlorinator Salt", + "value": 0, + "unit": "ppm", + "state_type": "measurement" + } + }, + "configuration": { + "pool_setpoint": { + "name": "Pool Chlorinator Setpoint", + "value": 51, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 0 + }, + "spa_setpoint": { + "name": "Spa Chlorinator Setpoint", + "value": 0, + "unit": "%", + "min_setpoint": 0, + "max_setpoint": 100, + "step": 5, + "body_type": 1 + }, + "super_chlor_timer": { + "name": "Super Chlorination Timer", + "value": 0, + "unit": "hr", + "min_setpoint": 1, + "max_setpoint": 72, + "step": 1 + } + }, + "flags": 0 + } +} diff --git a/tests/components/screenlogic/snapshots/test_diagnostics.ambr b/tests/components/screenlogic/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..05320c147e5a79 --- /dev/null +++ b/tests/components/screenlogic/snapshots/test_diagnostics.ambr @@ -0,0 +1,960 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'config_entry': dict({ + 'data': dict({ + 'ip_address': '127.0.0.1', + 'port': 80, + }), + 'disabled_by': None, + 'domain': 'screenlogic', + 'entry_id': 'screenlogictest', + 'options': dict({ + 'scan_interval': 30, + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Pentair DD-EE-FF', + 'unique_id': 'aa:bb:cc:dd:ee:ff', + 'version': 1, + }), + 'data': dict({ + 'adapter': dict({ + 'firmware': dict({ + 'name': 'Protocol Adapter Firmware', + 'value': 'POOL: 5.2 Build 736.0 Rel', + }), + }), + 'body': dict({ + '0': dict({ + 'body_type': 0, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Cool Set Point', + 'unit': '°F', + 'value': 100, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Pool Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Pool Heat Set Point', + 'unit': '°F', + 'value': 83, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Pool Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Pool Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Pool', + }), + '1': dict({ + 'body_type': 1, + 'cool_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Cool Set Point', + 'unit': '°F', + 'value': 69, + }), + 'heat_mode': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Solar Preferred', + 'Heater', + "Don't Change", + ]), + 'name': 'Spa Heat Mode', + 'value': 0, + }), + 'heat_setpoint': dict({ + 'device_type': 'temperature', + 'name': 'Spa Heat Set Point', + 'unit': '°F', + 'value': 94, + }), + 'heat_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Off', + 'Solar', + 'Heater', + 'Both', + ]), + 'name': 'Spa Heat', + 'value': 0, + }), + 'last_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Last Spa Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 84, + }), + 'max_setpoint': 104, + 'min_setpoint': 40, + 'name': 'Spa', + }), + }), + 'circuit': dict({ + '500': dict({ + 'circuit_id': 500, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 71, + 'unknown_at_offset_62': 0, + 'unknown_at_offset_63': 0, + }), + 'device_id': 1, + 'function': 1, + 'interface': 1, + 'name': 'Spa', + 'value': 0, + }), + '501': dict({ + 'circuit_id': 501, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 85, + 'unknown_at_offset_94': 0, + 'unknown_at_offset_95': 0, + }), + 'device_id': 2, + 'function': 0, + 'interface': 2, + 'name': 'Waterfall', + 'value': 0, + }), + '502': dict({ + 'circuit_id': 502, + 'color': dict({ + 'color_position': 0, + 'color_set': 2, + 'color_stagger': 2, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 62, + 'unknown_at_offset_126': 0, + 'unknown_at_offset_127': 0, + }), + 'device_id': 3, + 'function': 16, + 'interface': 3, + 'name': 'Pool Light', + 'value': 0, + }), + '503': dict({ + 'circuit_id': 503, + 'color': dict({ + 'color_position': 1, + 'color_set': 6, + 'color_stagger': 10, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 73, + 'unknown_at_offset_158': 0, + 'unknown_at_offset_159': 0, + }), + 'device_id': 4, + 'function': 16, + 'interface': 3, + 'name': 'Spa Light', + 'value': 0, + }), + '504': dict({ + 'circuit_id': 504, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 240, + 'delay': 0, + 'flags': 0, + 'name_index': 21, + 'unknown_at_offset_186': 0, + 'unknown_at_offset_187': 0, + }), + 'device_id': 5, + 'function': 5, + 'interface': 0, + 'name': 'Cleaner', + 'value': 0, + }), + '505': dict({ + 'circuit_id': 505, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 1, + 'name_index': 63, + 'unknown_at_offset_214': 0, + 'unknown_at_offset_215': 0, + }), + 'device_id': 6, + 'function': 2, + 'interface': 0, + 'name': 'Pool Low', + 'value': 0, + }), + '506': dict({ + 'circuit_id': 506, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 91, + 'unknown_at_offset_246': 0, + 'unknown_at_offset_247': 0, + }), + 'device_id': 7, + 'function': 7, + 'interface': 4, + 'name': 'Yard Light', + 'value': 0, + }), + '507': dict({ + 'circuit_id': 507, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 1620, + 'delay': 0, + 'flags': 0, + 'name_index': 101, + 'unknown_at_offset_274': 0, + 'unknown_at_offset_275': 0, + }), + 'device_id': 8, + 'function': 0, + 'interface': 2, + 'name': 'Cameras', + 'value': 1, + }), + '508': dict({ + 'circuit_id': 508, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_306': 0, + 'unknown_at_offset_307': 0, + }), + 'device_id': 9, + 'function': 0, + 'interface': 0, + 'name': 'Pool High', + 'value': 0, + }), + '510': dict({ + 'circuit_id': 510, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 78, + 'unknown_at_offset_334': 0, + 'unknown_at_offset_335': 0, + }), + 'device_id': 11, + 'function': 14, + 'interface': 1, + 'name': 'Spillway', + 'value': 0, + }), + '511': dict({ + 'circuit_id': 511, + 'color': dict({ + 'color_position': 0, + 'color_set': 0, + 'color_stagger': 0, + }), + 'configuration': dict({ + 'default_runtime': 720, + 'delay': 0, + 'flags': 0, + 'name_index': 61, + 'unknown_at_offset_366': 0, + 'unknown_at_offset_367': 0, + }), + 'device_id': 12, + 'function': 0, + 'interface': 5, + 'name': 'Pool High', + 'value': 0, + }), + }), + 'controller': dict({ + 'configuration': dict({ + 'body_type': dict({ + '0': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + '1': dict({ + 'max_setpoint': 104, + 'min_setpoint': 40, + }), + }), + 'circuit_count': 11, + 'color': list([ + dict({ + 'name': 'White', + 'value': list([ + 255, + 255, + 255, + ]), + }), + dict({ + 'name': 'Light Green', + 'value': list([ + 160, + 255, + 160, + ]), + }), + dict({ + 'name': 'Green', + 'value': list([ + 0, + 255, + 80, + ]), + }), + dict({ + 'name': 'Cyan', + 'value': list([ + 0, + 255, + 200, + ]), + }), + dict({ + 'name': 'Blue', + 'value': list([ + 100, + 140, + 255, + ]), + }), + dict({ + 'name': 'Lavender', + 'value': list([ + 230, + 130, + 255, + ]), + }), + dict({ + 'name': 'Magenta', + 'value': list([ + 255, + 0, + 128, + ]), + }), + dict({ + 'name': 'Light Magenta', + 'value': list([ + 255, + 180, + 210, + ]), + }), + ]), + 'color_count': 8, + 'controller_data': 0, + 'controller_type': 13, + 'generic_circuit_name': 'Water Features', + 'hardware_type': 0, + 'interface_tab_flags': 127, + 'is_celsius': dict({ + 'name': 'Is Celsius', + 'value': 0, + }), + 'remotes': 0, + 'show_alarms': 0, + 'unknown_at_offset_09': 0, + 'unknown_at_offset_10': 0, + 'unknown_at_offset_11': 0, + }), + 'controller_id': 100, + 'equipment': dict({ + 'flags': 98360, + 'list': list([ + 'INTELLIBRITE', + 'INTELLIFLO_0', + 'INTELLIFLO_1', + 'INTELLICHEM', + 'HYBRID_HEATER', + ]), + }), + 'model': dict({ + 'name': 'Model', + 'value': 'EasyTouch2 8', + }), + 'sensor': dict({ + 'active_alert': dict({ + 'device_type': 'alarm', + 'name': 'Active Alert', + 'value': 0, + }), + 'air_temperature': dict({ + 'device_type': 'temperature', + 'name': 'Air Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 69, + }), + 'cleaner_delay': dict({ + 'name': 'Cleaner Delay', + 'value': 0, + }), + 'freeze_mode': dict({ + 'name': 'Freeze Mode', + 'value': 0, + }), + 'orp': dict({ + 'name': 'ORP', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 728, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph': dict({ + 'name': 'pH', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 7.61, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'pool_delay': dict({ + 'name': 'Pool Delay', + 'value': 0, + }), + 'salt_ppm': dict({ + 'name': 'Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + 'spa_delay': dict({ + 'name': 'Spa Delay', + 'value': 0, + }), + 'state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Unknown', + 'Ready', + 'Sync', + 'Service', + ]), + 'name': 'Controller State', + 'value': 1, + }), + }), + }), + 'intellichem': dict({ + 'alarm': dict({ + 'flags': 1, + 'flow_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Flow Alarm', + 'value': 1, + }), + 'orp_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP HIGH Alarm', + 'value': 0, + }), + 'orp_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP LOW Alarm', + 'value': 0, + }), + 'orp_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'ORP Supply Alarm', + 'value': 0, + }), + 'ph_high_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH HIGH Alarm', + 'value': 0, + }), + 'ph_low_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH LOW Alarm', + 'value': 0, + }), + 'ph_supply_alarm': dict({ + 'device_type': 'alarm', + 'name': 'pH Supply Alarm', + 'value': 0, + }), + 'probe_fault_alarm': dict({ + 'device_type': 'alarm', + 'name': 'Probe Fault', + 'value': 0, + }), + }), + 'alert': dict({ + 'flags': 0, + 'orp_limit': dict({ + 'name': 'ORP Dose Limit Reached', + 'value': 0, + }), + 'ph_limit': dict({ + 'name': 'pH Dose Limit Reached', + 'value': 0, + }), + 'ph_lockout': dict({ + 'name': 'pH Lockout', + 'value': 0, + }), + }), + 'configuration': dict({ + 'calcium_harness': dict({ + 'name': 'Calcium Hardness', + 'unit': 'ppm', + 'value': 800, + }), + 'cya': dict({ + 'name': 'Cyanuric Acid', + 'unit': 'ppm', + 'value': 45, + }), + 'flags': 32, + 'orp_setpoint': dict({ + 'name': 'ORP Setpoint', + 'unit': 'mV', + 'value': 720, + }), + 'ph_setpoint': dict({ + 'name': 'pH Setpoint', + 'unit': 'pH', + 'value': 7.6, + }), + 'probe_is_celsius': 0, + 'salt_tds_ppm': dict({ + 'name': 'Salt/TDS', + 'unit': 'ppm', + 'value': 1000, + }), + 'total_alkalinity': dict({ + 'name': 'Total Alkalinity', + 'unit': 'ppm', + 'value': 45, + }), + }), + 'dose_status': dict({ + 'flags': 149, + 'orp_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'ORP Dosing State', + 'value': 2, + }), + 'orp_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last ORP Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 4, + }), + 'orp_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last ORP Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + 'ph_dosing_state': dict({ + 'device_type': 'enum', + 'enum_options': list([ + 'Dosing', + 'Mixing', + 'Monitoring', + ]), + 'name': 'pH Dosing State', + 'value': 1, + }), + 'ph_last_dose_time': dict({ + 'device_type': 'duration', + 'name': 'Last pH Dose Time', + 'state_type': 'total_increasing', + 'unit': 'sec', + 'value': 5, + }), + 'ph_last_dose_volume': dict({ + 'device_type': 'volume', + 'name': 'Last pH Dose Volume', + 'state_type': 'total_increasing', + 'unit': 'mL', + 'value': 8, + }), + }), + 'firmware': dict({ + 'name': 'IntelliChem Firmware', + 'value': '1.060', + }), + 'sensor': dict({ + 'orp_now': dict({ + 'name': 'ORP Now', + 'state_type': 'measurement', + 'unit': 'mV', + 'value': 0, + }), + 'orp_supply_level': dict({ + 'name': 'ORP Supply Level', + 'state_type': 'measurement', + 'value': 3, + }), + 'ph_now': dict({ + 'name': 'pH Now', + 'state_type': 'measurement', + 'unit': 'pH', + 'value': 0.0, + }), + 'ph_probe_water_temp': dict({ + 'device_type': 'temperature', + 'name': 'pH Probe Water Temperature', + 'state_type': 'measurement', + 'unit': '°F', + 'value': 81, + }), + 'ph_supply_level': dict({ + 'name': 'pH Supply Level', + 'state_type': 'measurement', + 'value': 2, + }), + 'saturation': dict({ + 'name': 'Saturation Index', + 'state_type': 'measurement', + 'unit': 'lsi', + 'value': 0.06, + }), + }), + 'unknown_at_offset_00': 42, + 'unknown_at_offset_04': 0, + 'unknown_at_offset_44': 0, + 'unknown_at_offset_45': 0, + 'unknown_at_offset_46': 0, + 'water_balance': dict({ + 'corrosive': dict({ + 'device_type': 'alarm', + 'name': 'SI Corrosive', + 'value': 0, + }), + 'flags': 0, + 'scaling': dict({ + 'device_type': 'alarm', + 'name': 'SI Scaling', + 'value': 0, + }), + }), + }), + 'pump': dict({ + '0': dict({ + 'data': 70, + 'gpm_now': dict({ + 'name': 'Pool Low Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 6, + 'is_rpm': 0, + 'setpoint': 63, + }), + '1': dict({ + 'device_id': 9, + 'is_rpm': 0, + 'setpoint': 72, + }), + '2': dict({ + 'device_id': 1, + 'is_rpm': 1, + 'setpoint': 3450, + }), + '3': dict({ + 'device_id': 130, + 'is_rpm': 0, + 'setpoint': 75, + }), + '4': dict({ + 'device_id': 12, + 'is_rpm': 0, + 'setpoint': 72, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Pool Low Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Pool Low Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Pool Low Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '1': dict({ + 'data': 66, + 'gpm_now': dict({ + 'name': 'Waterfall Pump GPM Now', + 'state_type': 'measurement', + 'unit': 'gpm', + 'value': 0, + }), + 'preset': dict({ + '0': dict({ + 'device_id': 2, + 'is_rpm': 1, + 'setpoint': 2700, + }), + '1': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '2': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '3': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '4': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '5': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '6': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + '7': dict({ + 'device_id': 0, + 'is_rpm': 0, + 'setpoint': 30, + }), + }), + 'rpm_now': dict({ + 'name': 'Waterfall Pump RPM Now', + 'state_type': 'measurement', + 'unit': 'rpm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Waterfall Pump', + 'value': 0, + }), + 'type': 3, + 'unknown_at_offset_16': 0, + 'unknown_at_offset_24': 255, + 'watts_now': dict({ + 'device_type': 'power', + 'name': 'Waterfall Pump Watts Now', + 'state_type': 'measurement', + 'unit': 'W', + 'value': 0, + }), + }), + '2': dict({ + 'data': 0, + }), + '3': dict({ + 'data': 0, + }), + '4': dict({ + 'data': 0, + }), + '5': dict({ + 'data': 0, + }), + '6': dict({ + 'data': 0, + }), + '7': dict({ + 'data': 0, + }), + }), + 'scg': dict({ + 'configuration': dict({ + 'pool_setpoint': dict({ + 'body_type': 0, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Pool Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 51, + }), + 'spa_setpoint': dict({ + 'body_type': 1, + 'max_setpoint': 100, + 'min_setpoint': 0, + 'name': 'Spa Chlorinator Setpoint', + 'step': 5, + 'unit': '%', + 'value': 0, + }), + 'super_chlor_timer': dict({ + 'max_setpoint': 72, + 'min_setpoint': 1, + 'name': 'Super Chlorination Timer', + 'step': 1, + 'unit': 'hr', + 'value': 0, + }), + }), + 'flags': 0, + 'scg_present': 0, + 'sensor': dict({ + 'salt_ppm': dict({ + 'name': 'Chlorinator Salt', + 'state_type': 'measurement', + 'unit': 'ppm', + 'value': 0, + }), + 'state': dict({ + 'name': 'Chlorinator', + 'value': 0, + }), + }), + }), + }), + 'debug': dict({ + }), + }) +# --- diff --git a/tests/components/screenlogic/test_config_flow.py b/tests/components/screenlogic/test_config_flow.py index f2c39e05b4873d..14488c66564703 100644 --- a/tests/components/screenlogic/test_config_flow.py +++ b/tests/components/screenlogic/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import patch from screenlogicpy import ScreenLogicError -from screenlogicpy.const import ( +from screenlogicpy.const.common import ( SL_GATEWAY_IP, SL_GATEWAY_NAME, SL_GATEWAY_PORT, diff --git a/tests/components/screenlogic/test_data.py b/tests/components/screenlogic/test_data.py new file mode 100644 index 00000000000000..9686dc81586031 --- /dev/null +++ b/tests/components/screenlogic/test_data.py @@ -0,0 +1,91 @@ +"""Tests for ScreenLogic integration data processing.""" +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway +from screenlogicpy.const.data import ATTR, DEVICE, GROUP, VALUE + +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.screenlogic.data import PathPart, realize_path_template +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import ( + DATA_MIN_ENTITY_CLEANUP, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +async def test_async_cleanup_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test cleanup of unused entities.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_UNUSED_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_saturation", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Saturation Index", + "disabled_by": None, + "has_entity_name": True, + "original_name": "Saturation Index", + } + + unused_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_UNUSED_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + assert unused_entity + assert unused_entity.unique_id == TEST_UNUSED_ENTRY["unique_id"] + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_MIN_ENTITY_CLEANUP, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + deleted_entity = entity_registry.async_get(unused_entity.entity_id) + assert deleted_entity is None + + +def test_realize_path_templates() -> None: + """Test path template realization.""" + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX), (DEVICE.PUMP, 0, VALUE.WATTS_NOW) + ) == (DEVICE.PUMP, 0) + + assert realize_path_template( + (PathPart.DEVICE, PathPart.INDEX, PathPart.VALUE, ATTR.NAME_INDEX), + (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION), + ) == (DEVICE.CIRCUIT, 500, GROUP.CONFIGURATION, ATTR.NAME_INDEX) + + with pytest.raises(KeyError): + realize_path_template( + (PathPart.DEVICE, PathPart.KEY, ATTR.VALUE), + (DEVICE.ADAPTER, VALUE.FIRMWARE), + ) diff --git a/tests/components/screenlogic/test_diagnostics.py b/tests/components/screenlogic/test_diagnostics.py new file mode 100644 index 00000000000000..dcbca954730dd1 --- /dev/null +++ b/tests/components/screenlogic/test_diagnostics.py @@ -0,0 +1,56 @@ +"""Testing for ScreenLogic diagnostics.""" +from unittest.mock import DEFAULT, patch + +from screenlogicpy import ScreenLogicGateway +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import ( + DATA_FULL_CHEM, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + stub_async_connect, +) + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + mock_config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + + device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=lambda *args, **kwargs: stub_async_connect( + DATA_FULL_CHEM, *args, **kwargs + ), + is_connected=True, + _async_connected_request=DEFAULT, + get_debug=lambda self: {}, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + diag = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diag == snapshot diff --git a/tests/components/screenlogic/test_init.py b/tests/components/screenlogic/test_init.py new file mode 100644 index 00000000000000..3b99354a1df4f0 --- /dev/null +++ b/tests/components/screenlogic/test_init.py @@ -0,0 +1,236 @@ +"""Tests for ScreenLogic integration init.""" +from dataclasses import dataclass +from unittest.mock import DEFAULT, patch + +import pytest +from screenlogicpy import ScreenLogicGateway + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.screenlogic import DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util import slugify + +from . import ( + DATA_MIN_MIGRATION, + GATEWAY_DISCOVERY_IMPORT_PATH, + MOCK_ADAPTER_MAC, + MOCK_ADAPTER_NAME, + stub_async_connect, +) + +from tests.common import MockConfigEntry + + +@dataclass +class EntityMigrationData: + """Class to organize minimum entity data.""" + + old_name: str + old_key: str + new_name: str + new_key: str + domain: str + + +TEST_MIGRATING_ENTITIES = [ + EntityMigrationData( + "Chemistry Alarm", + "chem_alarm", + "Active Alert", + "active_alert", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Pool Low Pump Current Watts", + "currentWatts_0", + "Pool Low Pump Watts Now", + "pump_0_watts_now", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "SCG Status", + "scg_status", + "Chlorinator", + "scg_state", + BINARY_SENSOR_DOMAIN, + ), + EntityMigrationData( + "Non-Migrating Sensor", + "nonmigrating", + "Non-Migrating Sensor", + "nonmigrating", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Cyanuric Acid", + "chem_cya", + "Cyanuric Acid", + "chem_cya", + SENSOR_DOMAIN, + ), + EntityMigrationData( + "Old Sensor", + "old_sensor", + "Old Sensor", + "old_sensor", + SENSOR_DOMAIN, + ), +] + +MIGRATION_CONNECT = lambda *args, **kwargs: stub_async_connect( + DATA_MIN_MIGRATION, *args, **kwargs +) + + +@pytest.mark.parametrize( + ("entity_def", "ent_data"), + [ + ( + { + "domain": ent_data.domain, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} {ent_data.old_name}", + "disabled_by": None, + "has_entity_name": True, + "original_name": ent_data.old_name, + }, + ent_data, + ) + for ent_data in TEST_MIGRATING_ENTITIES + ], + ids=[ent_data.old_name for ent_data in TEST_MIGRATING_ENTITIES], +) +async def test_async_migrate_entries( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_def: dict, + ent_data: EntityMigrationData, +) -> None: + """Test migration to new entity names.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_cya", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} CYA", + "disabled_by": None, + "has_entity_name": True, + "original_name": "CYA", + } + + entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + entity: er.RegistryEntry = entity_registry.async_get_or_create( + **entity_def, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.old_name}')}" + old_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.old_key}" + new_eid = f"{ent_data.domain}.{slugify(f'{MOCK_ADAPTER_NAME} {ent_data.new_name}')}" + new_uid = f"{MOCK_ADAPTER_MAC}_{ent_data.new_key}" + + assert entity.unique_id == old_uid + assert entity.entity_id == old_eid + + with patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get(new_eid) + assert entity_migrated + assert entity_migrated.entity_id == new_eid + assert entity_migrated.unique_id == new_uid + assert entity_migrated.original_name == ent_data.new_name + + +async def test_entity_migration_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test ENTITY_MIGRATION data guards.""" + + mock_config_entry.add_to_hass(hass) + + entity_registry = er.async_get(hass) + device_registry = dr.async_get(hass) + + device: dr.DeviceEntry = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, MOCK_ADAPTER_MAC)}, + ) + + TEST_EXISTING_ENTRY = { + "domain": SENSOR_DOMAIN, + "platform": DOMAIN, + "unique_id": f"{MOCK_ADAPTER_MAC}_missing_device", + "suggested_object_id": f"{MOCK_ADAPTER_NAME} Missing Migration Device", + "disabled_by": None, + "has_entity_name": True, + "original_name": "EMissing Migration Device", + } + + original_entity: er.RegistryEntry = entity_registry.async_get_or_create( + **TEST_EXISTING_ENTRY, device_id=device.id, config_entry=mock_config_entry + ) + + old_eid = original_entity.entity_id + old_uid = original_entity.unique_id + + assert old_uid == f"{MOCK_ADAPTER_MAC}_missing_device" + assert ( + old_eid + == f"{SENSOR_DOMAIN}.{slugify(f'{MOCK_ADAPTER_NAME} Missing Migration Device')}" + ) + + # This patch simulates bad data being added to ENTITY_MIGRATIONS + with patch.dict( + "homeassistant.components.screenlogic.data.ENTITY_MIGRATIONS", + { + "missing_device": { + "new_key": "state", + "old_name": "Missing Migration Device", + "new_name": "Bad ENTITY_MIGRATIONS Entry", + }, + }, + ), patch( + GATEWAY_DISCOVERY_IMPORT_PATH, + return_value={}, + ), patch.multiple( + ScreenLogicGateway, + async_connect=MIGRATION_CONNECT, + is_connected=True, + _async_connected_request=DEFAULT, + ): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_migrated = entity_registry.async_get( + slugify(f"{MOCK_ADAPTER_NAME} Bad ENTITY_MIGRATIONS Entry") + ) + assert entity_migrated is None + + entity_not_migrated = entity_registry.async_get(old_eid) + assert entity_not_migrated == original_entity From 602e36aa12c2218840728cea21b9db0f286d98da Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Sat, 9 Sep 2023 18:40:28 -0400 Subject: [PATCH 286/640] Add new sensors to Roborock (#99983) * Add 3 new sensor types * add state options for dock error * add unit of measurement --- homeassistant/components/roborock/sensor.py | 36 ++++++++++++++++++- .../components/roborock/strings.json | 17 +++++++++ tests/components/roborock/test_sensor.py | 3 +- 3 files changed, 54 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 0629839f01bba7..8d58ae96c453d4 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -4,7 +4,12 @@ from collections.abc import Callable from dataclasses import dataclass -from roborock.containers import RoborockErrorCode, RoborockStateCode +from roborock.containers import ( + RoborockDockErrorCode, + RoborockDockTypeCode, + RoborockErrorCode, + RoborockStateCode, +) from roborock.roborock_typing import DeviceProp from homeassistant.components.sensor import ( @@ -134,6 +139,35 @@ class RoborockSensorDescription( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + # Only available on some newer models + RoborockSensorDescription( + key="clean_percent", + icon="mdi:progress-check", + translation_key="clean_percent", + value_fn=lambda data: data.status.clean_percent, + entity_category=EntityCategory.DIAGNOSTIC, + native_unit_of_measurement=PERCENTAGE, + ), + # Only available with more than just the basic dock + RoborockSensorDescription( + 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, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=RoborockDockErrorCode.keys(), + ), + RoborockSensorDescription( + key="mop_clean_remaining", + native_unit_of_measurement=UnitOfTime.SECONDS, + device_class=SensorDeviceClass.DURATION, + value_fn=lambda data: data.status.rdt, + translation_key="mop_drying_remaining_time", + entity_category=EntityCategory.DIAGNOSTIC, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 269bbf04cf20ad..0170c8ac706186 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -50,9 +50,26 @@ "cleaning_time": { "name": "Cleaning time" }, + "clean_percent": { + "name": "Cleaning progress" + }, + "dock_error": { + "name": "Dock error", + "state": { + "ok": "Ok", + "duct_blockage": "Duct blockage", + "water_empty": "Water empty", + "waste_water_tank_full": "Waste water tank full", + "dirty_tank_latch_open": "Dirty tank latch open", + "no_dustbin": "No dustbin" + } + }, "main_brush_time_left": { "name": "Main brush time left" }, + "mop_drying_remaining_time": { + "name": "Mop drying remaining time" + }, "side_brush_time_left": { "name": "Side brush time left" }, diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 19648343bb48d2..a022f0dfa51400 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 11 + assert len(hass.states.async_all("sensor")) == 12 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -38,3 +38,4 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_cleaning_area").state == "21.0" assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" + assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" From 3b588a839cad95d42e67eecd174cc0732db0b646 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 9 Sep 2023 19:49:26 -0500 Subject: [PATCH 287/640] Bump zeroconf to 0.103.0 (#100012) --- homeassistant/components/zeroconf/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/zeroconf/test_init.py | 4 +++- 5 files changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zeroconf/manifest.json b/homeassistant/components/zeroconf/manifest.json index e97c430d35db39..d3fd3654997572 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.102.0"] + "requirements": ["zeroconf==0.103.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0857591e120d16..f9144efba8df4c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.102.0 +zeroconf==0.103.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 371765cb1d19e0..f43b02e847e12f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2767,7 +2767,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.102.0 +zeroconf==0.103.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e01d005d9814a..f57a5ac4a3d535 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2043,7 +2043,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.102.0 +zeroconf==0.103.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index b07e2d5880a123..a6ff257d78cea5 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -220,7 +220,7 @@ async def test_setup_with_overly_long_url_and_name( " string long string long string long string long string" ), ), patch( - "homeassistant.components.zeroconf.AsyncServiceInfo.request", + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) @@ -1219,6 +1219,8 @@ async def test_setup_with_disallowed_characters_in_local_name( hass.config, "location_name", "My.House", + ), patch( + "homeassistant.components.zeroconf.AsyncServiceInfo.async_request", ): assert await async_setup_component(hass, zeroconf.DOMAIN, {zeroconf.DOMAIN: {}}) hass.bus.async_fire(EVENT_HOMEASSISTANT_START) From 4153181cd3c1a2ce65eb77f110bae6787c241bd5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 03:17:59 -0500 Subject: [PATCH 288/640] Bump aiodiscover to 1.5.1 (#100020) changelog: https://github.com/bdraco/aiodiscover/compare/v1.4.16...v1.5.1 --- homeassistant/components/dhcp/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/dhcp/manifest.json b/homeassistant/components/dhcp/manifest.json index e65966fbaa29b0..3d9a55780457c5 100644 --- a/homeassistant/components/dhcp/manifest.json +++ b/homeassistant/components/dhcp/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_push", "loggers": ["aiodiscover", "dnspython", "pyroute2", "scapy"], "quality_scale": "internal", - "requirements": ["scapy==2.5.0", "aiodiscover==1.4.16"] + "requirements": ["scapy==2.5.0", "aiodiscover==1.5.1"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f9144efba8df4c..b40c0198dfe284 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,4 +1,4 @@ -aiodiscover==1.4.16 +aiodiscover==1.5.1 aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 diff --git a/requirements_all.txt b/requirements_all.txt index f43b02e847e12f..612cf1b1f45ba2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -213,7 +213,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f57a5ac4a3d535..295f0e97ef3760 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -194,7 +194,7 @@ aiobotocore==2.6.0 aiocomelit==0.0.5 # homeassistant.components.dhcp -aiodiscover==1.4.16 +aiodiscover==1.5.1 # homeassistant.components.dnsip # homeassistant.components.minecraft_server From 1f3b3b1be3ffec4431d309663909963afbd4d22f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Sun, 10 Sep 2023 10:20:26 +0200 Subject: [PATCH 289/640] Add sensor entity descriptions in Minecraft Server (#99971) * Add sensor entity descriptions * Fix review findings * Fix type of value function to avoid inline lambda if conditions and add attribute function to avoid extra sensor entity class * Correct name of binary sensor base entity * Simplify adding of entities in platforms * Do not use keyword arguments while adding entities --- .../minecraft_server/binary_sensor.py | 54 ++-- .../components/minecraft_server/entity.py | 15 +- .../components/minecraft_server/sensor.py | 235 ++++++++---------- 3 files changed, 146 insertions(+), 158 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 3721a50b1ded3a..51978d388b6e9e 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -1,7 +1,10 @@ """The Minecraft Server binary sensor platform.""" +from dataclasses import dataclass + from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, + BinarySensorEntityDescription, ) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -12,6 +15,21 @@ from .entity import MinecraftServerEntity +@dataclass +class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class describing Minecraft Server binary sensor entities.""" + + +BINARY_SENSOR_DESCRIPTIONS = [ + MinecraftServerBinarySensorEntityDescription( + key=KEY_STATUS, + translation_key=KEY_STATUS, + device_class=BinarySensorDeviceClass.CONNECTIVITY, + icon=ICON_STATUS, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -20,28 +38,32 @@ async def async_setup_entry( """Set up the Minecraft Server binary sensor platform.""" server = hass.data[DOMAIN][config_entry.entry_id] - # Create entities list. - entities = [MinecraftServerStatusBinarySensor(server)] - # Add binary sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerBinarySensorEntity(server, description) + for description in BINARY_SENSOR_DESCRIPTIONS + ], + True, + ) -class MinecraftServerStatusBinarySensor(MinecraftServerEntity, BinarySensorEntity): - """Representation of a Minecraft Server status binary sensor.""" +class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntity): + """Representation of a Minecraft Server binary sensor base entity.""" - _attr_translation_key = KEY_STATUS + entity_description: MinecraftServerBinarySensorEntityDescription - def __init__(self, server: MinecraftServer) -> None: - """Initialize status binary sensor.""" - super().__init__( - server=server, - entity_type=KEY_STATUS, - icon=ICON_STATUS, - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ) + def __init__( + self, + server: MinecraftServer, + description: MinecraftServerBinarySensorEntityDescription, + ) -> None: + """Initialize binary sensor base entity.""" + super().__init__(server=server) + self.entity_description = description + self._attr_unique_id = f"{server.unique_id}-{description.key}" self._attr_is_on = False async def async_update(self) -> None: - """Update status.""" + """Update binary sensor state.""" self._attr_is_on = self._server.online diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index 9048cb94004845..4702b42beb9a95 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -19,23 +19,16 @@ class MinecraftServerEntity(Entity): def __init__( self, server: MinecraftServer, - entity_type: str, - icon: str, - device_class: str | None, ) -> None: """Initialize base entity.""" self._server = server - self._attr_icon = icon - self._attr_unique_id = f"{self._server.unique_id}-{entity_type}" self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._server.unique_id)}, + identifiers={(DOMAIN, server.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({self._server.data.version})", - name=self._server.name, - sw_version=f"{self._server.data.protocol_version}", + model=f"Minecraft Server ({server.data.version})", + name=server.name, + sw_version=str(server.data.protocol_version), ) - self._attr_device_class = device_class - self._extra_state_attributes = None self._disconnect_dispatcher: CALLBACK_TYPE | None = None async def async_update(self) -> None: diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index e17050310a8d3c..cb3be3e58d78fd 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -1,13 +1,18 @@ """The Minecraft Server sensor platform.""" from __future__ import annotations -from homeassistant.components.sensor import SensorEntity +from collections.abc import Callable, MutableMapping +from dataclasses import dataclass +from typing import Any + +from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType -from . import MinecraftServer +from . import MinecraftServer, MinecraftServerData from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -29,6 +34,84 @@ from .entity import MinecraftServerEntity +@dataclass +class MinecraftServerEntityDescriptionMixin: + """Mixin values for Minecraft Server entities.""" + + value_fn: Callable[[MinecraftServerData], StateType] + attributes_fn: Callable[[MinecraftServerData], MutableMapping[str, Any]] | None + + +@dataclass +class MinecraftServerSensorEntityDescription( + SensorEntityDescription, MinecraftServerEntityDescriptionMixin +): + """Class describing Minecraft Server sensor entities.""" + + +def get_extra_state_attributes_players_list( + data: MinecraftServerData, +) -> dict[str, list[str]]: + """Return players list as extra state attributes, if available.""" + extra_state_attributes = {} + players_list = data.players_list + + if players_list is not None and len(players_list) != 0: + extra_state_attributes[ATTR_PLAYERS_LIST] = players_list + + return extra_state_attributes + + +SENSOR_DESCRIPTIONS = [ + MinecraftServerSensorEntityDescription( + key=KEY_VERSION, + translation_key=KEY_VERSION, + icon=ICON_VERSION, + value_fn=lambda data: data.version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PROTOCOL_VERSION, + translation_key=KEY_PROTOCOL_VERSION, + icon=ICON_PROTOCOL_VERSION, + value_fn=lambda data: data.protocol_version, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_MAX, + translation_key=KEY_PLAYERS_MAX, + native_unit_of_measurement=UNIT_PLAYERS_MAX, + icon=ICON_PLAYERS_MAX, + value_fn=lambda data: data.players_max, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_LATENCY, + translation_key=KEY_LATENCY, + native_unit_of_measurement=UnitOfTime.MILLISECONDS, + suggested_display_precision=0, + icon=ICON_LATENCY, + value_fn=lambda data: data.latency, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_MOTD, + translation_key=KEY_MOTD, + icon=ICON_MOTD, + value_fn=lambda data: data.motd, + attributes_fn=None, + ), + MinecraftServerSensorEntityDescription( + key=KEY_PLAYERS_ONLINE, + translation_key=KEY_PLAYERS_ONLINE, + native_unit_of_measurement=UNIT_PLAYERS_ONLINE, + icon=ICON_PLAYERS_ONLINE, + value_fn=lambda data: data.players_online, + attributes_fn=get_extra_state_attributes_players_list, + ), +] + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -37,151 +120,41 @@ async def async_setup_entry( """Set up the Minecraft Server sensor platform.""" server = hass.data[DOMAIN][config_entry.entry_id] - # Create entities list. - entities = [ - MinecraftServerVersionSensor(server), - MinecraftServerProtocolVersionSensor(server), - MinecraftServerLatencySensor(server), - MinecraftServerPlayersOnlineSensor(server), - MinecraftServerPlayersMaxSensor(server), - MinecraftServerMOTDSensor(server), - ] - # Add sensor entities. - async_add_entities(entities, True) + async_add_entities( + [ + MinecraftServerSensorEntity(server, description) + for description in SENSOR_DESCRIPTIONS + ], + True, + ) class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): """Representation of a Minecraft Server sensor base entity.""" + entity_description: MinecraftServerSensorEntityDescription + def __init__( self, server: MinecraftServer, - entity_type: str, - icon: str, - unit: str | None = None, - device_class: str | None = None, + description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server, entity_type, icon, device_class) - self._attr_native_unit_of_measurement = unit + super().__init__(server) + self.entity_description = description + self._attr_unique_id = f"{server.unique_id}-{description.key}" @property def available(self) -> bool: """Return sensor availability.""" return self._server.online - -class MinecraftServerVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server version sensor.""" - - _attr_translation_key = KEY_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize version sensor.""" - super().__init__(server=server, entity_type=KEY_VERSION, icon=ICON_VERSION) - - async def async_update(self) -> None: - """Update version.""" - self._attr_native_value = self._server.data.version - - -class MinecraftServerProtocolVersionSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server protocol version sensor.""" - - _attr_translation_key = KEY_PROTOCOL_VERSION - - def __init__(self, server: MinecraftServer) -> None: - """Initialize protocol version sensor.""" - super().__init__( - server=server, - entity_type=KEY_PROTOCOL_VERSION, - icon=ICON_PROTOCOL_VERSION, - ) - - async def async_update(self) -> None: - """Update protocol version.""" - self._attr_native_value = self._server.data.protocol_version - - -class MinecraftServerLatencySensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server latency sensor.""" - - _attr_translation_key = KEY_LATENCY - - def __init__(self, server: MinecraftServer) -> None: - """Initialize latency sensor.""" - super().__init__( - server=server, - entity_type=KEY_LATENCY, - icon=ICON_LATENCY, - unit=UnitOfTime.MILLISECONDS, - ) - async def async_update(self) -> None: - """Update latency.""" - self._attr_native_value = self._server.data.latency + """Update sensor state.""" + self._attr_native_value = self.entity_description.value_fn(self._server.data) - -class MinecraftServerPlayersOnlineSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server online players sensor.""" - - _attr_translation_key = KEY_PLAYERS_ONLINE - - def __init__(self, server: MinecraftServer) -> None: - """Initialize online players sensor.""" - super().__init__( - server=server, - entity_type=KEY_PLAYERS_ONLINE, - icon=ICON_PLAYERS_ONLINE, - unit=UNIT_PLAYERS_ONLINE, - ) - - async def async_update(self) -> None: - """Update online players state and device state attributes.""" - self._attr_native_value = self._server.data.players_online - - extra_state_attributes = {} - players_list = self._server.data.players_list - - if players_list is not None and len(players_list) != 0: - extra_state_attributes[ATTR_PLAYERS_LIST] = players_list - - self._attr_extra_state_attributes = extra_state_attributes - - -class MinecraftServerPlayersMaxSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server maximum number of players sensor.""" - - _attr_translation_key = KEY_PLAYERS_MAX - - def __init__(self, server: MinecraftServer) -> None: - """Initialize maximum number of players sensor.""" - super().__init__( - server=server, - entity_type=KEY_PLAYERS_MAX, - icon=ICON_PLAYERS_MAX, - unit=UNIT_PLAYERS_MAX, - ) - - async def async_update(self) -> None: - """Update maximum number of players.""" - self._attr_native_value = self._server.data.players_max - - -class MinecraftServerMOTDSensor(MinecraftServerSensorEntity): - """Representation of a Minecraft Server MOTD sensor.""" - - _attr_translation_key = KEY_MOTD - - def __init__(self, server: MinecraftServer) -> None: - """Initialize MOTD sensor.""" - super().__init__( - server=server, - entity_type=KEY_MOTD, - icon=ICON_MOTD, - ) - - async def async_update(self) -> None: - """Update MOTD.""" - self._attr_native_value = self._server.data.motd + if self.entity_description.attributes_fn: + self._attr_extra_state_attributes = self.entity_description.attributes_fn( + self._server.data + ) From c01a9987b5afd796c47804283400e90d498dbe01 Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 10 Sep 2023 11:34:09 +0200 Subject: [PATCH 290/640] Add Plugwise temperature_offset number (#100029) Add temperature_offset number --- homeassistant/components/plugwise/number.py | 10 ++++++++++ .../components/plugwise/strings.json | 3 +++ tests/components/plugwise/test_number.py | 20 +++++++++++++++++++ 3 files changed, 33 insertions(+) diff --git a/homeassistant/components/plugwise/number.py b/homeassistant/components/plugwise/number.py index 6fd3f7f92da5b7..7e387abea02805 100644 --- a/homeassistant/components/plugwise/number.py +++ b/homeassistant/components/plugwise/number.py @@ -60,6 +60,16 @@ class PlugwiseNumberEntityDescription( entity_category=EntityCategory.CONFIG, native_unit_of_measurement=UnitOfTemperature.CELSIUS, ), + PlugwiseNumberEntityDescription( + key="temperature_offset", + translation_key="temperature_offset", + command=lambda api, number, dev_id, value: api.set_temperature_offset( + number, dev_id, value + ), + device_class=NumberDeviceClass.TEMPERATURE, + entity_category=EntityCategory.CONFIG, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + ), ) diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 5210f8a6dc0b2e..2714d657267b29 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -79,6 +79,9 @@ }, "max_dhw_temperature": { "name": "Domestic hot water setpoint" + }, + "temperature_offset": { + "name": "Temperature offset" } }, "select": { diff --git a/tests/components/plugwise/test_number.py b/tests/components/plugwise/test_number.py index bccf257a433090..6572a0df20eeb8 100644 --- a/tests/components/plugwise/test_number.py +++ b/tests/components/plugwise/test_number.py @@ -69,3 +69,23 @@ async def test_adam_dhw_setpoint_change( mock_smile_adam_2.set_number_setpoint.assert_called_with( "max_dhw_temperature", "056ee145a816487eaa69243c3280f8bf", 55.0 ) + + +async def test_adam_temperature_offset_change( + hass: HomeAssistant, mock_smile_adam: MagicMock, init_integration: MockConfigEntry +) -> None: + """Test changing of number entities.""" + await hass.services.async_call( + NUMBER_DOMAIN, + SERVICE_SET_VALUE, + { + ATTR_ENTITY_ID: "number.zone_thermostat_jessie_temperature_offset", + ATTR_VALUE: 1.0, + }, + blocking=True, + ) + + assert mock_smile_adam.set_temperature_offset.call_count == 1 + mock_smile_adam.set_temperature_offset.assert_called_with( + "temperature_offset", "6a3bf693d05e48e0b460c815a4fdd09d", 1.0 + ) From 446ca2e9ad125a31e85c11097c8c9053a5a1f54d Mon Sep 17 00:00:00 2001 From: Bouwe Westerdijk <11290930+bouwew@users.noreply.github.com> Date: Sun, 10 Sep 2023 12:16:59 +0200 Subject: [PATCH 291/640] Enable strict typing in Plugwise (#100033) Add plugwise to .strict-typing --- .strict-typing | 1 + mypy.ini | 10 ++++++++++ 2 files changed, 11 insertions(+) diff --git a/.strict-typing b/.strict-typing index ee97deb9af47ea..852ebbc0420534 100644 --- a/.strict-typing +++ b/.strict-typing @@ -257,6 +257,7 @@ homeassistant.components.peco.* homeassistant.components.persistent_notification.* homeassistant.components.pi_hole.* homeassistant.components.ping.* +homeassistant.components.plugwise.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* homeassistant.components.proximity.* diff --git a/mypy.ini b/mypy.ini index eda6f35cdfa604..6bade2728f41f5 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2332,6 +2332,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.plugwise.*] +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.powerwall.*] check_untyped_defs = true disallow_incomplete_defs = true From e4af50f9555d0f1fda8793e0760b95379f030b28 Mon Sep 17 00:00:00 2001 From: fender4645 Date: Sun, 10 Sep 2023 03:58:18 -0700 Subject: [PATCH 292/640] Add debug message to doods (#100002) * Debug message if no detections found or no output file configured * fix formatting * black --------- Co-authored-by: Jan Bouwhuis --- homeassistant/components/doods/image_processing.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/doods/image_processing.py b/homeassistant/components/doods/image_processing.py index c94fff1124e6e5..ba97dbe38ecaf5 100644 --- a/homeassistant/components/doods/image_processing.py +++ b/homeassistant/components/doods/image_processing.py @@ -392,6 +392,10 @@ def process_image(self, image): else: paths.append(path_template) self._save_image(image, matches, paths) + else: + _LOGGER.debug( + "Not saving image(s), no detections found or no output file configured" + ) self._matches = matches self._total_matches = total_matches From ad4619c03817b2e15220248be49c614618f288db Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 08:25:13 -0500 Subject: [PATCH 293/640] Speed up serializing event messages (#100017) --- homeassistant/components/websocket_api/messages.py | 4 +++- homeassistant/core.py | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index e5fd5626302503..1114eec4fac5c5 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -94,7 +94,9 @@ def _cached_event_message(event: Event) -> str: The IDEN_TEMPLATE is used which will be replaced with the actual iden in cached_event_message """ - return message_to_json({"id": IDEN_TEMPLATE, "type": "event", "event": event}) + return message_to_json( + {"id": IDEN_TEMPLATE, "type": "event", "event": event.as_dict()} + ) def cached_state_diff_message(iden: int, event: Event) -> str: diff --git a/homeassistant/core.py b/homeassistant/core.py index 2ffe51a4f3adfd..17b8b5f2e85da2 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -953,7 +953,7 @@ def as_dict(self) -> ReadOnlyDict[str, Any]: { "event_type": self.event_type, "data": ReadOnlyDict(self.data), - "origin": str(self.origin.value), + "origin": self.origin.value, "time_fired": self.time_fired.isoformat(), "context": self.context.as_dict(), } From 5e81499855c59ad331fc6208910391d141389fda Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 08:25:23 -0500 Subject: [PATCH 294/640] Avoid json_decoder_fallback in /api/states (#100018) --- homeassistant/components/api/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 6aead6e109f989..a1a2d1107b9c97 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -270,7 +270,7 @@ async def post(self, request, entity_id): # Read the state back for our response status_code = HTTPStatus.CREATED if is_new_state else HTTPStatus.OK - resp = self.json(hass.states.get(entity_id), status_code) + resp = self.json(hass.states.get(entity_id).as_dict(), status_code) resp.headers.add("Location", f"/api/states/{entity_id}") From 553cdfbf9945eb9a7f5efdf9e697cca3de776dce Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 10 Sep 2023 14:29:38 +0100 Subject: [PATCH 295/640] Always update unit of measurement of the utility_meter on state change (#99102) --- .../components/utility_meter/sensor.py | 8 +++++ tests/components/utility_meter/test_sensor.py | 33 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index f3e86136f5d4b8..cd581d8c37f10c 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -17,6 +17,7 @@ SensorExtraStoredData, SensorStateClass, ) +from homeassistant.components.sensor.recorder import _suggest_report_issue from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -484,6 +485,12 @@ def async_reading(self, event: EventType[EventStateChangedData]) -> None: DATA_TARIFF_SENSORS ]: sensor.start(new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)) + if self._unit_of_measurement is None: + _LOGGER.warning( + "Source sensor %s has no unit of measurement. Please %s", + self._sensor_source_id, + _suggest_report_issue(self.hass, self._sensor_source_id), + ) if ( adjustment := self.calculate_adjustment(old_state, new_state) @@ -491,6 +498,7 @@ def async_reading(self, event: EventType[EventStateChangedData]) -> None: # If net_consumption is off, the adjustment must be non-negative self._state += adjustment # type: ignore[operator] # self._state will be set to by the start function if it is None, therefore it always has a valid Decimal value at this line + self._unit_of_measurement = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) self._last_valid_state = new_state_val self.async_write_ha_state() diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index b8f197a4dee21b..43d68d87362caa 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -1460,6 +1460,39 @@ def test_calculate_adjustment_invalid_new_state( assert "Invalid state unknown" in caplog.text +async def test_unit_of_measurement_missing_invalid_new_state( + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test that a suggestion is created when new_state is missing unit_of_measurement.""" + yaml_config = { + "utility_meter": { + "energy_bill": { + "source": "sensor.energy", + } + } + } + source_entity_id = yaml_config[DOMAIN]["energy_bill"]["source"] + + assert await async_setup_component(hass, DOMAIN, yaml_config) + await hass.async_block_till_done() + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) + await hass.async_block_till_done() + + hass.states.async_set(source_entity_id, 4, {ATTR_UNIT_OF_MEASUREMENT: None}) + + await hass.async_block_till_done() + + state = hass.states.get("sensor.energy_bill") + assert state is not None + assert state.state == "0" + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + assert ( + f"Source sensor {source_entity_id} has no unit of measurement." in caplog.text + ) + + async def test_device_id(hass: HomeAssistant) -> None: """Test for source entity device for Utility Meter.""" device_registry = dr.async_get(hass) From ccca12cf3169b2c5ab9b22879c05688a83185d0f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 10 Sep 2023 15:42:47 +0200 Subject: [PATCH 296/640] Update bthome-ble to 3.1.1 (#100042) --- homeassistant/components/bthome/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/bthome/manifest.json b/homeassistant/components/bthome/manifest.json index 7f53a5b5f0634c..01db154306f5e8 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.0"] + "requirements": ["bthome-ble==3.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 612cf1b1f45ba2..00a2560c603a6c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -579,7 +579,7 @@ brunt==1.2.0 bt-proximity==0.2.1 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.bt_home_hub_5 bthomehub5-devicelist==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 295f0e97ef3760..5e689e05c789db 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -486,7 +486,7 @@ brottsplatskartan==0.0.1 brunt==1.2.0 # homeassistant.components.bthome -bthome-ble==3.1.0 +bthome-ble==3.1.1 # homeassistant.components.buienradar buienradar==1.0.5 From d56ad146732beab72d66a72d2607a92aa6a669f2 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 10 Sep 2023 09:49:56 -0400 Subject: [PATCH 297/640] Add diagnostic platform to Honeywell (#100046) Add diagnostic platform --- .../components/honeywell/diagnostics.py | 33 ++++++++++++ tests/components/honeywell/conftest.py | 43 +++++++++++++++ .../honeywell/snapshots/test_diagnostics.ambr | 53 +++++++++++++++++++ .../components/honeywell/test_diagnostics.py | 35 ++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 homeassistant/components/honeywell/diagnostics.py create mode 100644 tests/components/honeywell/snapshots/test_diagnostics.ambr create mode 100644 tests/components/honeywell/test_diagnostics.py diff --git a/homeassistant/components/honeywell/diagnostics.py b/homeassistant/components/honeywell/diagnostics.py new file mode 100644 index 00000000000000..4aebfc4c905e3a --- /dev/null +++ b/homeassistant/components/honeywell/diagnostics.py @@ -0,0 +1,33 @@ +"""Diagnostics support for Honeywell.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from . import HoneywellData +from .const import DOMAIN + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, + config_entry: ConfigEntry, +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + Honeywell: HoneywellData = hass.data[DOMAIN][config_entry.entry_id] + diagnostics_data = {} + + for device, module in Honeywell.devices.items(): + diagnostics_data.update( + { + f"Device {device}": { + "UI Data": module.raw_ui_data, + "Fan Data": module.raw_fan_data, + "DR Data": module.raw_dr_data, + } + } + ) + + return diagnostics_data diff --git a/tests/components/honeywell/conftest.py b/tests/components/honeywell/conftest.py index 8406d76803a330..876050586d27b8 100644 --- a/tests/components/honeywell/conftest.py +++ b/tests/components/honeywell/conftest.py @@ -108,6 +108,8 @@ def device(): mock_device.heat_away_temp = HEATAWAY mock_device.cool_away_temp = COOLAWAY + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -127,6 +129,27 @@ def device_with_outdoor_sensor(): mock_device.temperature_unit = "C" mock_device.outdoor_temperature = OUTDOORTEMP mock_device.outdoor_humidity = OUTDOORHUMIDITY + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} + return mock_device @@ -145,6 +168,26 @@ def another_device(): mock_device.mac_address = "macaddress1" mock_device.outdoor_temperature = None mock_device.outdoor_humidity = None + mock_device.raw_ui_data = { + "SwitchOffAllowed": True, + "SwitchAutoAllowed": True, + "SwitchCoolAllowed": True, + "SwitchHeatAllowed": True, + "SwitchEmergencyHeatAllowed": True, + "HeatUpperSetptLimit": HEATUPPERSETPOINTLIMIT, + "HeatLowerSetptLimit": HEATLOWERSETPOINTLIMIT, + "CoolUpperSetptLimit": COOLUPPERSETPOINTLIMIT, + "CoolLowerSetptLimit": COOLLOWERSETPOINTLIMIT, + "HeatNextPeriod": NEXTHEATPERIOD, + "CoolNextPeriod": NEXTCOOLPERIOD, + } + mock_device.raw_fan_data = { + "fanModeOnAllowed": True, + "fanModeAutoAllowed": True, + "fanModeCirculateAllowed": True, + } + + mock_device.raw_dr_data = {"CoolSetpLimit": None, "HeatSetpLimit": None} return mock_device diff --git a/tests/components/honeywell/snapshots/test_diagnostics.ambr b/tests/components/honeywell/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..3077fc747deb10 --- /dev/null +++ b/tests/components/honeywell/snapshots/test_diagnostics.ambr @@ -0,0 +1,53 @@ +# serializer version: 1 +# name: test_entry_diagnostics + dict({ + 'Device 1234567': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + 'Device 7654321': dict({ + 'DR Data': dict({ + 'CoolSetpLimit': None, + 'HeatSetpLimit': None, + }), + 'Fan Data': dict({ + 'fanModeAutoAllowed': True, + 'fanModeCirculateAllowed': True, + 'fanModeOnAllowed': True, + }), + 'UI Data': dict({ + 'CoolLowerSetptLimit': 10, + 'CoolNextPeriod': 10, + 'CoolUpperSetptLimit': 20, + 'HeatLowerSetptLimit': 20, + 'HeatNextPeriod': 10, + 'HeatUpperSetptLimit': 35, + 'SwitchAutoAllowed': True, + 'SwitchCoolAllowed': True, + 'SwitchEmergencyHeatAllowed': True, + 'SwitchHeatAllowed': True, + 'SwitchOffAllowed': True, + }), + }), + }) +# --- diff --git a/tests/components/honeywell/test_diagnostics.py b/tests/components/honeywell/test_diagnostics.py new file mode 100644 index 00000000000000..aafc50d5545dca --- /dev/null +++ b/tests/components/honeywell/test_diagnostics.py @@ -0,0 +1,35 @@ +"""Test Honeywell diagnostics.""" +from unittest.mock import MagicMock + +from syrupy import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +YAML_CONFIG = {"username": "test-user", "password": "test-password"} + + +async def test_entry_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, +) -> None: + """Test config entry diagnostics for Honeywell.""" + + location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert hass.states.async_entity_ids_count() == 6 + + result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry) + + assert result == snapshot From 59e87c0864f5d7205866090cf05a9593f8fce942 Mon Sep 17 00:00:00 2001 From: mkmer Date: Sun, 10 Sep 2023 09:58:59 -0400 Subject: [PATCH 298/640] Raise HomeAssistantError/ValueError for service calls in Honeywell (#100041) --- homeassistant/components/honeywell/climate.py | 49 +- tests/components/honeywell/test_climate.py | 477 ++++++++++-------- 2 files changed, 310 insertions(+), 216 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index b23df9f1f4b458..d12d90a02c3607 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -27,6 +27,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -315,6 +316,9 @@ async def _set_temperature(self, **kwargs) -> None: except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature {temperature}." + ) from err async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" @@ -328,14 +332,23 @@ async def async_set_temperature(self, **kwargs: Any) -> None: except SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) + raise ValueError( + f"Honeywell set temperature failed: invalid temperature: {temperature}." + ) from err async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" - await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + try: + await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set fan mode.") from err async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" - await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + try: + await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) + except SomeComfortError as err: + raise HomeAssistantError("Honeywell could not set system mode.") from err async def _turn_away_mode_on(self) -> None: """Turn away on. @@ -355,13 +368,16 @@ async def _turn_away_mode_on(self) -> None: if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, self._heat_away_temp, self._cool_away_temp, ) + raise ValueError( + f"Honeywell set temperature failed: temperature out of range. Mode: {mode}, Heat Temperuature: {self._heat_away_temp}, Cool Temperature: {self._cool_away_temp}." + ) from err async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" @@ -376,10 +392,14 @@ async def _turn_hold_mode_on(self) -> None: if mode in HEATING_MODES: await self._device.set_hold_heat(True) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Couldn't set permanent hold") + raise HomeAssistantError( + "Honeywell couldn't set permanent hold." + ) from err else: _LOGGER.error("Invalid system mode returned: %s", mode) + raise HomeAssistantError(f"Honeywell invalid system mode returned {mode}.") async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" @@ -388,8 +408,9 @@ async def _turn_away_mode_off(self) -> None: # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) - except SomeComfortError: + except SomeComfortError as err: _LOGGER.error("Can not stop hold mode") + raise HomeAssistantError("Honeywell could not stop hold mode") from err async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" @@ -403,14 +424,22 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" - await self._device.set_system_mode("emheat") + try: + await self._device.set_system_mode("emheat") + except SomeComfortError as err: + raise HomeAssistantError( + "Honeywell could not set system mode to aux heat." + ) from err async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" - if HVACMode.HEAT in self.hvac_modes: - await self.async_set_hvac_mode(HVACMode.HEAT) - else: - await self.async_set_hvac_mode(HVACMode.OFF) + try: + if HVACMode.HEAT in self.hvac_modes: + await self.async_set_hvac_mode(HVACMode.HEAT) + else: + await self.async_set_hvac_mode(HVACMode.OFF) + except HomeAssistantError as err: + raise HomeAssistantError("Honeywell could turn off aux heat mode.") from err async def async_update(self) -> None: """Get the latest state from the service.""" diff --git a/tests/components/honeywell/test_climate.py b/tests/components/honeywell/test_climate.py index 92caa29b71f331..7bd76cb8522f94 100644 --- a/tests/components/honeywell/test_climate.py +++ b/tests/components/honeywell/test_climate.py @@ -37,6 +37,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.util.dt import utcnow @@ -193,6 +194,15 @@ async def test_mode_service_calls( device.set_system_mode.assert_called_once_with("auto") device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_HVAC_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_HVAC_MODE: HVACMode.HEAT_COOL}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("auto") async def test_auxheat_service_calls( @@ -211,6 +221,7 @@ async def test_auxheat_service_calls( device.set_system_mode.assert_called_once_with("emheat") device.set_system_mode.reset_mock() + await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_AUX_HEAT, @@ -219,6 +230,27 @@ async def test_auxheat_service_calls( ) device.set_system_mode.assert_called_once_with("heat") + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: True}, + blocking=True, + ) + device.set_system_mode.assert_called_once_with("emheat") + + device.set_system_mode.reset_mock() + device.set_system_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_AUX_HEAT, + {ATTR_ENTITY_ID: entity_id, ATTR_AUX_HEAT: False}, + blocking=True, + ) + async def test_fan_modes_service_calls( hass: HomeAssistant, device: MagicMock, config_entry: MagicMock @@ -256,6 +288,17 @@ async def test_fan_modes_service_calls( device.set_fan_mode.assert_called_once_with("circulate") + device.set_fan_mode.reset_mock() + + device.set_fan_mode.side_effect = aiosomecomfort.SomeComfortError + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_FAN_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_FAN_MODE: FAN_DIFFUSE}, + blocking=True, + ) + async def test_service_calls_off_mode( hass: HomeAssistant, @@ -299,16 +342,18 @@ async def test_service_calls_off_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -387,7 +432,6 @@ async def test_service_calls_off_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, @@ -443,16 +487,18 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_setpoint_cool.reset_mock() device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -474,12 +520,13 @@ async def test_service_calls_cool_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_not_called() @@ -491,12 +538,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -504,12 +552,13 @@ async def test_service_calls_cool_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_cool.assert_called_once() @@ -519,25 +568,25 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -546,13 +595,13 @@ async def test_service_calls_cool_mode( caplog.clear() device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -563,12 +612,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -580,13 +630,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -599,12 +649,13 @@ async def test_service_calls_cool_mode( device.raw_ui_data["StatusCool"] = 2 device.system_mode = "Junk" - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_not_called() device.set_hold_heat.assert_not_called() @@ -640,13 +691,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(datetime.time(2, 30), 59) device.set_hold_heat.reset_mock() assert "Invalid temperature" in caplog.text @@ -667,16 +718,17 @@ async def test_service_calls_heat_mode( device.set_setpoint_heat.reset_mock() device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_cool.assert_called_with(95) device.set_setpoint_heat.assert_called_with(77) assert "Invalid temperature" in caplog.text @@ -685,12 +737,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -698,12 +751,13 @@ async def test_service_calls_heat_mode( device.hold_heat = True device.hold_cool = True - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: "20"}, + blocking=True, + ) device.set_setpoint_heat.assert_called_once() @@ -715,24 +769,26 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() assert "Couldn't set permanent hold" in caplog.text reset_mock(device) - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -743,12 +799,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True, 22) device.set_hold_cool.assert_not_called() @@ -757,13 +814,13 @@ async def test_service_calls_heat_mode( reset_mock(device) caplog.clear() - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) @@ -771,13 +828,13 @@ async def test_service_calls_heat_mode( device.set_hold_heat.reset_mock() device.set_hold_cool.reset_mock() device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) assert "Can not stop hold mode" in caplog.text @@ -786,12 +843,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -802,12 +860,13 @@ async def test_service_calls_heat_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(True) device.set_hold_cool.assert_not_called() @@ -863,13 +922,13 @@ async def test_service_calls_auto_mode( device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError device.set_hold_heat.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + {ATTR_ENTITY_ID: entity_id, ATTR_TEMPERATURE: 15}, + blocking=True, + ) device.set_setpoint_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -878,16 +937,17 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = aiosomecomfort.SomeComfortError device.set_setpoint_cool.side_effect = aiosomecomfort.SomeComfortError - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_TEMPERATURE, - { - ATTR_ENTITY_ID: entity_id, - ATTR_TARGET_TEMP_LOW: 25.0, - ATTR_TARGET_TEMP_HIGH: 35.0, - }, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_TARGET_TEMP_LOW: 25.0, + ATTR_TARGET_TEMP_HIGH: 35.0, + }, + blocking=True, + ) device.set_setpoint_heat.assert_not_called() assert "Invalid temperature" in caplog.text @@ -917,12 +977,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_called_once_with(True) assert "Couldn't set permanent hold" in caplog.text @@ -931,12 +992,13 @@ async def test_service_calls_auto_mode( device.set_setpoint_heat.side_effect = None device.set_setpoint_cool.side_effect = None - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, - blocking=True, - ) + with pytest.raises(ValueError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_AWAY}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True, 12) device.set_hold_heat.assert_called_once_with(True, 22) @@ -944,25 +1006,26 @@ async def test_service_calls_auto_mode( reset_mock(device) caplog.clear() - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_called_once_with(False) device.set_hold_cool.assert_called_once_with(False) reset_mock(device) device.set_hold_cool.side_effect = aiosomecomfort.SomeComfortError - - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_NONE}, + blocking=True, + ) device.set_hold_heat.assert_not_called() device.set_hold_cool.assert_called_once_with(False) @@ -974,12 +1037,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() @@ -990,12 +1054,13 @@ async def test_service_calls_auto_mode( device.raw_ui_data["StatusHeat"] = 2 device.raw_ui_data["StatusCool"] = 2 - await hass.services.async_call( - CLIMATE_DOMAIN, - SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, - blocking=True, - ) + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_PRESET_MODE, + {ATTR_ENTITY_ID: entity_id, ATTR_PRESET_MODE: PRESET_HOLD}, + blocking=True, + ) device.set_hold_cool.assert_called_once_with(True) device.set_hold_heat.assert_not_called() From 739eb28b90adabb394a6ff57507c60ece03a01fa Mon Sep 17 00:00:00 2001 From: Yuxiang Zhu Date: Sun, 10 Sep 2023 22:07:35 +0800 Subject: [PATCH 299/640] Make homekit RTP/RTCP source ports more deterministic (#99989) --- .../components/homekit/type_cameras.py | 4 ++-- tests/components/homekit/test_type_cameras.py | 18 +++++++++--------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/homekit/type_cameras.py b/homeassistant/components/homekit/type_cameras.py index 62d27245a1c951..4c7ba5a7841e92 100644 --- a/homeassistant/components/homekit/type_cameras.py +++ b/homeassistant/components/homekit/type_cameras.py @@ -79,7 +79,7 @@ "-ssrc {v_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {v_srtp_key} " "srtp://{address}:{v_port}?rtcpport={v_port}&" - "localrtcpport={v_port}&pkt_size={v_pkt_size}" + "localrtpport={v_port}&pkt_size={v_pkt_size}" ) AUDIO_OUTPUT = ( @@ -92,7 +92,7 @@ "-ssrc {a_ssrc} -f rtp " "-srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params {a_srtp_key} " "srtp://{address}:{a_port}?rtcpport={a_port}&" - "localrtcpport={a_port}&pkt_size={a_pkt_size}" + "localrtpport={a_port}&pkt_size={a_pkt_size}" ) SLOW_RESOLUTIONS = [ diff --git a/tests/components/homekit/test_type_cameras.py b/tests/components/homekit/test_type_cameras.py index 9fcd36d06f35c4..fdb092467f32b3 100644 --- a/tests/components/homekit/test_type_cameras.py +++ b/tests/components/homekit/test_type_cameras.py @@ -187,11 +187,11 @@ async def test_camera_stream_source_configured( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a libopus -application lowdelay -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type " "110 -ssrc {a_ssrc} -f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -344,7 +344,7 @@ async def test_camera_stream_source_found( "yuv420p -r 30 -b:v 299k -bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f " "rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316" + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316" ) working_ffmpeg.open.assert_called_with( @@ -507,11 +507,11 @@ async def test_camera_stream_source_configured_and_copy_codec( "-map 0:v:0 -an -c:v copy -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -580,11 +580,11 @@ async def test_camera_stream_source_configured_and_override_profile_names( "-map 0:v:0 -an -c:v h264_v4l2m2m -profile:v 4 -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) working_ffmpeg.open.assert_called_with( @@ -654,11 +654,11 @@ async def test_camera_streaming_fails_after_starting_ffmpeg( "-map 0:v:0 -an -c:v h264_omx -profile:v high -tune zerolatency -pix_fmt yuv420p -r 30 -b:v 299k " "-bufsize 1196k -maxrate 299k -payload_type 99 -ssrc {v_ssrc} -f rtp -srtp_out_suite " "AES_CM_128_HMAC_SHA1_80 -srtp_out_params zdPmNLWeI86DtLJHvVLI6YPvqhVeeiLsNtrAgbgL " - "srtp://192.168.208.5:51246?rtcpport=51246&localrtcpport=51246&pkt_size=1316 -map 0:a:0 " + "srtp://192.168.208.5:51246?rtcpport=51246&localrtpport=51246&pkt_size=1316 -map 0:a:0 " "-vn -c:a copy -ac 1 -ar 24k -b:a 24k -bufsize 96k -payload_type 110 -ssrc {a_ssrc} " "-f rtp -srtp_out_suite AES_CM_128_HMAC_SHA1_80 -srtp_out_params " "shnETgfD+7xUQ8zRdsaytY11wu6CO73IJ+RZVJpU " - "srtp://192.168.208.5:51108?rtcpport=51108&localrtcpport=51108&pkt_size=188" + "srtp://192.168.208.5:51108?rtcpport=51108&localrtpport=51108&pkt_size=188" ) ffmpeg_with_invalid_pid.open.assert_called_with( From b165c28a7c9717465fa6c5315cbff99fa19c4816 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 16:18:45 +0200 Subject: [PATCH 300/640] Improve Withings config flow tests (#99697) * Decouple Withings sensor tests from yaml * Improve Withings config flow tests * Improve Withings config flow tests * Fix feedback * Rename CONF_PROFILE to PROFILE --- tests/components/withings/test_config_flow.py | 223 ++++++++++++------ 1 file changed, 154 insertions(+), 69 deletions(-) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index c8f3b4bbb29ae5..51403e672252f1 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,90 +1,173 @@ """Tests for config flow.""" -from http import HTTPStatus - -from aiohttp.test_utils import TestClient - -from homeassistant import config_entries -from homeassistant.components.withings import const -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant +from unittest.mock import patch + +from homeassistant.components.withings.const import DOMAIN, PROFILE +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import AUTH_CALLBACK_PATH -from homeassistant.setup import async_setup_component + +from .conftest import CLIENT_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator -async def test_config_non_unique_profile(hass: HomeAssistant) -> None: - """Test setup a non-unique profile.""" - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" - ) - config_entry.add_to_hass(hass) - +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check full flow.""" result = await hass.config_entries.flow.async_init( - const.DOMAIN, context={"source": "profile"}, data={const.PROFILE: "person0"} + 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 - assert result["errors"]["base"] == "already_configured" + 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" -async def test_config_reauth_profile( + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 600, + }, + }, + ) + with patch( + "homeassistant.components.withings.async_setup_entry", return_value=True + ) as mock_setup: + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "profile" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={PROFILE: "Henk"} + ) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert len(mock_setup.mock_calls) == 1 + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Henk" + assert "result" in result + assert result["result"].unique_id == "600" + assert "token" in result["result"].data + assert result["result"].data["token"]["access_token"] == "mock-access-token" + assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" + + +async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, - aioclient_mock: AiohttpClientMocker, current_request_with_host: None, + aioclient_mock: AiohttpClientMocker, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, + """Test setup a non-unique profile.""" + config_entry = MockConfigEntry(domain=DOMAIN, data={PROFILE: "Henk"}, unique_id="0") + config_entry.add_to_hass(hass) + 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", }, - } - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() + ) - config_entry = MockConfigEntry( - domain=const.DOMAIN, data={const.PROFILE: "person0"}, unique_id="0" + 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" ) - config_entry.add_to_hass(hass) - result = await hass.config_entries.flow.async_init( - const.DOMAIN, - context={ - "source": config_entries.SOURCE_REAUTH, - "entry_id": config_entry.entry_id, - "title_placeholders": {"name": config_entry.title}, - "unique_id": config_entry.unique_id, + 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": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": 10, + }, }, - data={"profile": "person0"}, ) + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "profile" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={PROFILE: "Henk"} + ) + assert result - assert result["type"] == "form" - assert result["step_id"] == "reauth_confirm" - assert result["description_placeholders"] == {const.PROFILE: "person0"} + assert result["errors"]["base"] == "already_configured" result = await hass.config_entries.flow.async_configure( - result["flow_id"], - {}, + result["flow_id"], user_input={PROFILE: "Henk 2"} ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Henk 2" + assert "result" in result + assert result["result"].unique_id == "10" + +async def test_config_reauth_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + current_request_with_host, +) -> None: + """Test reauth an existing profile re-creates the config entry.""" + config_entry.add_to_hass(hass) + + config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) state = config_entry_oauth2_flow._encode_jwt( hass, { @@ -92,10 +175,16 @@ async def test_config_reauth_profile( "redirect_uri": "https://example.com/auth/external/callback", }, ) - - client: TestClient = await hass_client_no_auth() - resp = await client.get(f"{AUTH_CALLBACK_PATH}?code=abcd&state={state}") - assert resp.status == HTTPStatus.OK + 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() @@ -114,9 +203,5 @@ async def test_config_reauth_profile( result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == "abort" - assert result["reason"] == "already_configured" - - entries = hass.config_entries.async_entries(const.DOMAIN) - assert entries - assert entries[0].data["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" From 140bc03fb1b0a977e8d6c6bdf3a8bc30cf1d8586 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 10 Sep 2023 16:02:42 +0100 Subject: [PATCH 301/640] Bump systembridgeconnector to 3.8.2 (#100051) Update systembridgeconnector to 3.8.2 --- 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 c0f89c16339888..bcc6189c8ef449 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.4.9"], + "requirements": ["systembridgeconnector==3.8.2"], "zeroconf": ["_system-bridge._tcp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 00a2560c603a6c..2ec37d4039263e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2505,7 +2505,7 @@ swisshydrodata==0.1.0 synology-srm==0.2.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5e689e05c789db..525861216f8cb6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1844,7 +1844,7 @@ sunwatcher==0.2.1 surepy==0.8.0 # homeassistant.components.system_bridge -systembridgeconnector==3.4.9 +systembridgeconnector==3.8.2 # homeassistant.components.tailscale tailscale==0.2.0 From 05635c913f12c7d9b19d93fdf912c69479e56ba7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sun, 10 Sep 2023 17:10:45 +0200 Subject: [PATCH 302/640] Add device to OpenUV (#100027) --- homeassistant/components/openuv/__init__.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index cb8d1bffceb7d3..4df91cf4e15efc 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -18,6 +18,7 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -126,6 +127,11 @@ def __init__( f"{coordinator.latitude}_{coordinator.longitude}_{description.key}" ) self.entity_description = description + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{coordinator.latitude}_{coordinator.longitude}")}, + name="OpenUV", + entry_type=DeviceEntryType.SERVICE, + ) @callback def _handle_coordinator_update(self) -> None: From 1a5f0933978cf260d7c64c96886306785c2e57ed Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 17:15:46 +0200 Subject: [PATCH 303/640] Uer hass.loop.create_future() for MQTT client (#100053) --- homeassistant/components/mqtt/__init__.py | 2 +- homeassistant/components/mqtt/util.py | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 9ec6447b32cd1d..50ab9dec36fbb7 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -248,7 +248,7 @@ async def _setup_client() -> tuple[MqttData, dict[str, Any]]: client_available: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - client_available = hass.data[DATA_MQTT_AVAILABLE] = asyncio.Future() + client_available = hass.data[DATA_MQTT_AVAILABLE] = hass.loop.create_future() else: client_available = hass.data[DATA_MQTT_AVAILABLE] diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 02d9964bcd14e1..6e364182cb02a9 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -63,7 +63,9 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: state_reached_future: asyncio.Future[bool] if DATA_MQTT_AVAILABLE not in hass.data: - hass.data[DATA_MQTT_AVAILABLE] = state_reached_future = asyncio.Future() + hass.data[ + DATA_MQTT_AVAILABLE + ] = state_reached_future = hass.loop.create_future() else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] if state_reached_future.done(): From 6899245020232cf703a7bf6907e3b64f49398cad Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 17:16:16 +0200 Subject: [PATCH 304/640] Use hass.loop.create_future() for bluetooth (#100054) --- homeassistant/components/bluetooth/api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/bluetooth/api.py b/homeassistant/components/bluetooth/api.py index be35a9d255d947..e364fd08e88f64 100644 --- a/homeassistant/components/bluetooth/api.py +++ b/homeassistant/components/bluetooth/api.py @@ -138,7 +138,7 @@ async def async_process_advertisements( timeout: int, ) -> BluetoothServiceInfoBleak: """Process advertisements until callback returns true or timeout expires.""" - done: Future[BluetoothServiceInfoBleak] = Future() + done: Future[BluetoothServiceInfoBleak] = hass.loop.create_future() @hass_callback def _async_discovered_device( From 51899ce5adff3e11ca569eeb5398cb5b8ba045ef Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Sun, 10 Sep 2023 16:32:52 +0100 Subject: [PATCH 305/640] Add System Bridge notifications (#82318) * System bridge notifications Add notify platform Add file to coverage Restore and fix lint after rebase Cleanup Use entity to register notify service Fix pylint Update package to 3.6.0 and add audio actions Update package to fix conflict Remove addition * Run pre-commit run --all-files * Update homeassistant/components/system_bridge/notify.py Co-authored-by: Joost Lekkerkerker * Format * Fix * Remove duplicate import --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + .../components/system_bridge/__init__.py | 32 +++++++- .../components/system_bridge/notify.py | 76 +++++++++++++++++++ 3 files changed, 106 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/system_bridge/notify.py diff --git a/.coveragerc b/.coveragerc index ecc835106ffbf9..23236891807fef 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1268,6 +1268,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/notify.py homeassistant/components/system_bridge/sensor.py homeassistant/components/systemmonitor/sensor.py homeassistant/components/tado/__init__.py diff --git a/homeassistant/components/system_bridge/__init__.py b/homeassistant/components/system_bridge/__init__.py index d50540f7b428cb..d13f5bcbdde3c0 100644 --- a/homeassistant/components/system_bridge/__init__.py +++ b/homeassistant/components/system_bridge/__init__.py @@ -20,7 +20,9 @@ from homeassistant.const import ( CONF_API_KEY, CONF_COMMAND, + CONF_ENTITY_ID, CONF_HOST, + CONF_NAME, CONF_PATH, CONF_PORT, CONF_URL, @@ -28,7 +30,11 @@ ) from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + discovery, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -40,6 +46,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.NOTIFY, Platform.SENSOR, ] @@ -142,7 +149,24 @@ async def async_setup_entry( hass.data.setdefault(DOMAIN, {}) hass.data[DOMAIN][entry.entry_id] = coordinator - await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + # Set up all platforms except notify + await hass.config_entries.async_forward_entry_setups( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) + + # Set up notify platform + hass.async_create_task( + discovery.async_load_platform( + hass, + Platform.NOTIFY, + DOMAIN, + { + CONF_NAME: f"{DOMAIN}_{coordinator.data.system.hostname}", + CONF_ENTITY_ID: entry.entry_id, + }, + hass.data[DOMAIN][entry.entry_id], + ) + ) if hass.services.has_service(DOMAIN, SERVICE_OPEN_URL): return True @@ -277,7 +301,9 @@ async def handle_send_text(call: ServiceCall) -> None: 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) + unload_ok = await hass.config_entries.async_unload_platforms( + entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY] + ) if unload_ok: coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ entry.entry_id diff --git a/homeassistant/components/system_bridge/notify.py b/homeassistant/components/system_bridge/notify.py new file mode 100644 index 00000000000000..1ad071bf78f941 --- /dev/null +++ b/homeassistant/components/system_bridge/notify.py @@ -0,0 +1,76 @@ +"""Support for System Bridge notification service.""" +from __future__ import annotations + +import logging +from typing import Any + +from systembridgeconnector.models.notification import Notification + +from homeassistant.components.notify import ( + ATTR_DATA, + ATTR_TITLE, + ATTR_TITLE_DEFAULT, + BaseNotificationService, +) +from homeassistant.const import ATTR_ICON, CONF_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType + +from .const import DOMAIN +from .coordinator import SystemBridgeDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + +ATTR_ACTIONS = "actions" +ATTR_AUDIO = "audio" +ATTR_IMAGE = "image" +ATTR_TIMEOUT = "timeout" + + +async def async_get_service( + hass: HomeAssistant, + config: ConfigType, + discovery_info: DiscoveryInfoType | None = None, +) -> SystemBridgeNotificationService | None: + """Get the System Bridge notification service.""" + if discovery_info is None: + return None + + coordinator: SystemBridgeDataUpdateCoordinator = hass.data[DOMAIN][ + discovery_info[CONF_ENTITY_ID] + ] + + return SystemBridgeNotificationService(coordinator) + + +class SystemBridgeNotificationService(BaseNotificationService): + """Implement the notification service for System Bridge.""" + + def __init__( + self, + coordinator: SystemBridgeDataUpdateCoordinator, + ) -> None: + """Initialize the service.""" + self._coordinator: SystemBridgeDataUpdateCoordinator = coordinator + + async def async_send_message( + self, + message: str = "", + **kwargs: Any, + ) -> None: + """Send a message.""" + data = kwargs.get(ATTR_DATA, {}) or {} + + notification = Notification( + actions=data.get(ATTR_ACTIONS), + audio=data.get(ATTR_AUDIO), + icon=data.get(ATTR_ICON), + image=data.get(ATTR_IMAGE), + message=message, + timeout=data.get(ATTR_TIMEOUT), + title=kwargs.get(ATTR_TITLE, data.get(ATTR_TITLE, ATTR_TITLE_DEFAULT)), + ) + + _LOGGER.debug("Sending notification: %s", notification.json()) + + await self._coordinator.websocket_client.send_notification(notification) From 50382a609c7270baff481c7190ad02f3b1359c0e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 11:24:57 -0500 Subject: [PATCH 306/640] Create recorder futures with loop.create_future() (#100049) --- homeassistant/components/recorder/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/core.py b/homeassistant/components/recorder/core.py index bbaff24ff778fb..8aa2bce96b1bf2 100644 --- a/homeassistant/components/recorder/core.py +++ b/homeassistant/components/recorder/core.py @@ -187,7 +187,7 @@ def __init__( self.auto_purge = auto_purge self.auto_repack = auto_repack self.keep_days = keep_days - self._hass_started: asyncio.Future[object] = asyncio.Future() + self._hass_started: asyncio.Future[object] = hass.loop.create_future() self.commit_interval = commit_interval self._queue: queue.SimpleQueue[RecorderTask] = queue.SimpleQueue() self.db_url = uri @@ -198,7 +198,7 @@ def __init__( db_connected: asyncio.Future[bool] = hass.data[DOMAIN].db_connected self.async_db_connected: asyncio.Future[bool] = db_connected # Database is ready to use but live migration may be in progress - self.async_db_ready: asyncio.Future[bool] = asyncio.Future() + self.async_db_ready: asyncio.Future[bool] = hass.loop.create_future() # Database is ready to use and all migration steps completed (used by tests) self.async_recorder_ready = asyncio.Event() self._queue_watch = threading.Event() From 63852c565fe01cb1bdd80dcd0286223dc6bdf2f5 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sun, 10 Sep 2023 18:25:25 +0200 Subject: [PATCH 307/640] Use hass.loop.create_future() in envisalink (#100057) --- homeassistant/components/envisalink/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/envisalink/__init__.py b/homeassistant/components/envisalink/__init__.py index 55ad58a030d0be..b0a4619bbf9e5c 100644 --- a/homeassistant/components/envisalink/__init__.py +++ b/homeassistant/components/envisalink/__init__.py @@ -124,7 +124,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: zones = conf.get(CONF_ZONES) partitions = conf.get(CONF_PARTITIONS) connection_timeout = conf.get(CONF_TIMEOUT) - sync_connect: asyncio.Future[bool] = asyncio.Future() + sync_connect: asyncio.Future[bool] = hass.loop.create_future() controller = EnvisalinkAlarmPanel( host, From 7acc606dd87b6c169ad9f11176f69344e726102d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sun, 10 Sep 2023 18:25:55 +0200 Subject: [PATCH 308/640] Remove unnecessary argument from discovergy coordinator (#100058) --- homeassistant/components/discovergy/__init__.py | 1 - homeassistant/components/discovergy/coordinator.py | 4 ---- 2 files changed, 5 deletions(-) diff --git a/homeassistant/components/discovergy/__init__.py b/homeassistant/components/discovergy/__init__.py index ab892cd9324e87..32f696a04ceadf 100644 --- a/homeassistant/components/discovergy/__init__.py +++ b/homeassistant/components/discovergy/__init__.py @@ -62,7 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: # so we have data when entities are added coordinator = DiscovergyUpdateCoordinator( hass=hass, - config_entry=entry, meter=meter, discovergy_client=discovergy_data.api_client, ) diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index d2548d0bacd335..1371b1f26ac177 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -8,7 +8,6 @@ from pydiscovergy.error import AccessTokenExpired, HTTPError from pydiscovergy.models import Meter, Reading -from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -21,19 +20,16 @@ class DiscovergyUpdateCoordinator(DataUpdateCoordinator[Reading]): """The Discovergy update coordinator.""" - config_entry: ConfigEntry discovergy_client: Discovergy meter: Meter def __init__( self, hass: HomeAssistant, - config_entry: ConfigEntry, meter: Meter, discovergy_client: Discovergy, ) -> None: """Initialize the Discovergy coordinator.""" - self.config_entry = config_entry self.meter = meter self.discovergy_client = discovergy_client From 3b25262d6cbfa3f95762223e25c58d2f6889d6f8 Mon Sep 17 00:00:00 2001 From: Tony <29752086+ms264556@users.noreply.github.com> Date: Sun, 10 Sep 2023 17:49:17 +0100 Subject: [PATCH 309/640] Address ruckus_unleashed late review (#99411) --- CODEOWNERS | 4 +- .../components/ruckus_unleashed/__init__.py | 18 +- .../ruckus_unleashed/config_flow.py | 74 ++++-- .../ruckus_unleashed/coordinator.py | 7 +- .../ruckus_unleashed/device_tracker.py | 18 +- .../components/ruckus_unleashed/manifest.json | 4 +- .../components/ruckus_unleashed/strings.json | 4 +- requirements_all.txt | 3 +- requirements_test_all.txt | 3 +- .../ruckus_unleashed/test_config_flow.py | 249 +++++++++++++++--- 10 files changed, 288 insertions(+), 96 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 6f7a0099494149..8a454cf775a533 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1064,8 +1064,8 @@ build.json @home-assistant/supervisor /tests/components/rss_feed_template/ @home-assistant/core /homeassistant/components/rtsp_to_webrtc/ @allenporter /tests/components/rtsp_to_webrtc/ @allenporter -/homeassistant/components/ruckus_unleashed/ @gabe565 @lanrat -/tests/components/ruckus_unleashed/ @gabe565 @lanrat +/homeassistant/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 +/tests/components/ruckus_unleashed/ @lanrat @ms264556 @gabe565 /homeassistant/components/ruuvi_gateway/ @akx /tests/components/ruuvi_gateway/ @akx /homeassistant/components/ruuvitag_ble/ @akx diff --git a/homeassistant/components/ruckus_unleashed/__init__.py b/homeassistant/components/ruckus_unleashed/__init__.py index e71555598cb2be..63521a622cda42 100644 --- a/homeassistant/components/ruckus_unleashed/__init__.py +++ b/homeassistant/components/ruckus_unleashed/__init__.py @@ -2,7 +2,7 @@ import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME @@ -31,16 +31,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Ruckus Unleashed from a config entry.""" + ruckus = AjaxSession.async_create( + entry.data[CONF_HOST], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + ) try: - ruckus = AjaxSession.async_create( - entry.data[CONF_HOST], - entry.data[CONF_USERNAME], - entry.data[CONF_PASSWORD], - ) await ruckus.login() - except (ConnectionRefusedError, ConnectionError) as conerr: + except (ConnectionError, SchemaError) as conerr: + await ruckus.close() raise ConfigEntryNotReady from conerr except AuthenticationError as autherr: + await ruckus.close() raise ConfigEntryAuthFailed from autherr coordinator = RuckusUnleashedDataUpdateCoordinator(hass, ruckus=ruckus) @@ -84,7 +86,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: for listener in hass.data[DOMAIN][entry.entry_id][UNDO_UPDATE_LISTENERS]: listener() - await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() + await hass.data[DOMAIN][entry.entry_id][COORDINATOR].ruckus.close() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/ruckus_unleashed/config_flow.py b/homeassistant/components/ruckus_unleashed/config_flow.py index 155eb68f5933d5..c11e9cbe89f1f8 100644 --- a/homeassistant/components/ruckus_unleashed/config_flow.py +++ b/homeassistant/components/ruckus_unleashed/config_flow.py @@ -1,9 +1,10 @@ """Config flow for Ruckus Unleashed integration.""" from collections.abc import Mapping +import logging from typing import Any from aioruckus import AjaxSession, SystemStat -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError import voluptuous as vol from homeassistant import config_entries, core, exceptions @@ -19,6 +20,8 @@ KEY_SYS_TITLE, ) +_LOGGER = logging.getLogger(__package__) + DATA_SCHEMA = vol.Schema( { vol.Required(CONF_HOST): str, @@ -38,26 +41,29 @@ async def validate_input(hass: core.HomeAssistant, data): async with AjaxSession.async_create( data[CONF_HOST], data[CONF_USERNAME], data[CONF_PASSWORD] ) as ruckus: - system_info = await ruckus.api.get_system_info( - SystemStat.SYSINFO, - ) - mesh_name = (await ruckus.api.get_mesh_info())[API_MESH_NAME] - zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] - return { - KEY_SYS_TITLE: mesh_name, - KEY_SYS_SERIAL: zd_serial, - } + mesh_info = await ruckus.api.get_mesh_info() + system_info = await ruckus.api.get_system_info(SystemStat.SYSINFO) except AuthenticationError as autherr: raise InvalidAuth from autherr - except (ConnectionRefusedError, ConnectionError, KeyError) as connerr: + except (ConnectionError, SchemaError) as connerr: raise CannotConnect from connerr + mesh_name = mesh_info[API_MESH_NAME] + zd_serial = system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] + + return { + KEY_SYS_TITLE: mesh_name, + KEY_SYS_SERIAL: zd_serial, + } + class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): """Handle a config flow for Ruckus Unleashed.""" VERSION = 1 + _reauth_entry: config_entries.ConfigEntry | None = None + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -70,30 +76,40 @@ async def async_step_user( errors["base"] = "cannot_connect" except InvalidAuth: errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" else: - await self.async_set_unique_id(info[KEY_SYS_SERIAL]) - self._abort_if_unique_id_configured() - return self.async_create_entry( - title=info[KEY_SYS_TITLE], data=user_input - ) - + if self._reauth_entry is None: + await self.async_set_unique_id(info[KEY_SYS_SERIAL]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=info[KEY_SYS_TITLE], data=user_input + ) + if info[KEY_SYS_SERIAL] == self._reauth_entry.unique_id: + self.hass.config_entries.async_update_entry( + self._reauth_entry, data=user_input + ) + self.hass.async_create_task( + self.hass.config_entries.async_reload( + self._reauth_entry.entry_id + ) + ) + return self.async_abort(reason="reauth_successful") + errors["base"] = "invalid_host" + + data_schema = self.add_suggested_values_to_schema( + DATA_SCHEMA, self._reauth_entry.data if self._reauth_entry else {} + ) return self.async_show_form( - step_id="user", data_schema=DATA_SCHEMA, errors=errors + step_id="user", data_schema=data_schema, errors=errors ) async def async_step_reauth(self, entry_data: Mapping[str, Any]) -> FlowResult: """Perform reauth upon an API authentication error.""" - 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.""" - if user_input is None: - return self.async_show_form( - step_id="reauth_confirm", - data_schema=DATA_SCHEMA, - ) + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) return await self.async_step_user() diff --git a/homeassistant/components/ruckus_unleashed/coordinator.py b/homeassistant/components/ruckus_unleashed/coordinator.py index 29df676cb76bea..7c11aac7f688a8 100644 --- a/homeassistant/components/ruckus_unleashed/coordinator.py +++ b/homeassistant/components/ruckus_unleashed/coordinator.py @@ -3,9 +3,10 @@ import logging from aioruckus import AjaxSession -from aioruckus.exceptions import AuthenticationError +from aioruckus.exceptions import AuthenticationError, SchemaError from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import API_CLIENT_MAC, DOMAIN, KEY_SYS_CLIENTS, SCAN_INTERVAL @@ -40,6 +41,6 @@ async def _async_update_data(self) -> dict: try: return {KEY_SYS_CLIENTS: await self._fetch_clients()} except AuthenticationError as autherror: - raise UpdateFailed(autherror) from autherror - except (ConnectionRefusedError, ConnectionError) as conerr: + raise ConfigEntryAuthFailed(autherror) from autherror + except (ConnectionError, SchemaError) as conerr: raise UpdateFailed(conerr) from conerr diff --git a/homeassistant/components/ruckus_unleashed/device_tracker.py b/homeassistant/components/ruckus_unleashed/device_tracker.py index 0e0d2f103c45bc..df5027ebaa8df2 100644 --- a/homeassistant/components/ruckus_unleashed/device_tracker.py +++ b/homeassistant/components/ruckus_unleashed/device_tracker.py @@ -103,20 +103,16 @@ def mac_address(self) -> str: @property def name(self) -> str: """Return the name.""" - return ( - self._name - if not self.is_connected - else self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] - ) + if not self.is_connected: + return self._name + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_HOSTNAME] @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the ip address.""" - return ( - self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] - if self.is_connected - else None - ) + if not self.is_connected: + return None + return self.coordinator.data[KEY_SYS_CLIENTS][self._mac][API_CLIENT_IP] @property def is_connected(self) -> bool: diff --git a/homeassistant/components/ruckus_unleashed/manifest.json b/homeassistant/components/ruckus_unleashed/manifest.json index 8ff69fb1aa93dd..edaf0aa95d269b 100644 --- a/homeassistant/components/ruckus_unleashed/manifest.json +++ b/homeassistant/components/ruckus_unleashed/manifest.json @@ -1,11 +1,11 @@ { "domain": "ruckus_unleashed", "name": "Ruckus Unleashed", - "codeowners": ["@gabe565", "@lanrat"], + "codeowners": ["@lanrat", "@ms264556", "@gabe565"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ruckus_unleashed", "integration_type": "hub", "iot_class": "local_polling", "loggers": ["aioruckus", "xmltodict"], - "requirements": ["aioruckus==0.31", "xmltodict==0.13.0"] + "requirements": ["aioruckus==0.34"] } diff --git a/homeassistant/components/ruckus_unleashed/strings.json b/homeassistant/components/ruckus_unleashed/strings.json index d6e3212b4eab64..769cde67d7aa8d 100644 --- a/homeassistant/components/ruckus_unleashed/strings.json +++ b/homeassistant/components/ruckus_unleashed/strings.json @@ -12,10 +12,12 @@ "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { - "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } } } diff --git a/requirements_all.txt b/requirements_all.txt index 2ec37d4039263e..169e88edcb9982 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -334,7 +334,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -2723,7 +2723,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 525861216f8cb6..1bb45b4996ce16 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -309,7 +309,7 @@ aiorecollect==2023.09.0 aioridwell==2023.07.0 # homeassistant.components.ruckus_unleashed -aioruckus==0.31 +aioruckus==0.34 # homeassistant.components.ruuvi_gateway aioruuvigateway==0.1.0 @@ -2011,7 +2011,6 @@ xknxproject==3.2.0 # homeassistant.components.bluesound # homeassistant.components.fritz # homeassistant.components.rest -# homeassistant.components.ruckus_unleashed # homeassistant.components.startca # homeassistant.components.ted5000 # homeassistant.components.zestimate diff --git a/tests/components/ruckus_unleashed/test_config_flow.py b/tests/components/ruckus_unleashed/test_config_flow.py index c55d531b0cb7be..cd74395fa66569 100644 --- a/tests/components/ruckus_unleashed/test_config_flow.py +++ b/tests/components/ruckus_unleashed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Ruckus Unleashed config flow.""" +from copy import deepcopy from datetime import timedelta from unittest.mock import AsyncMock, patch @@ -10,12 +11,22 @@ from aioruckus.exceptions import AuthenticationError from homeassistant import config_entries, data_entry_flow -from homeassistant.components.ruckus_unleashed.const import DOMAIN +from homeassistant.components.ruckus_unleashed.const import ( + API_SYS_SYSINFO, + API_SYS_SYSINFO_SERIAL, + DOMAIN, +) from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.util import utcnow -from . import CONFIG, DEFAULT_TITLE, RuckusAjaxApiPatchContext, mock_config_entry +from . import ( + CONFIG, + DEFAULT_SYSTEM_INFO, + DEFAULT_TITLE, + RuckusAjaxApiPatchContext, + mock_config_entry, +) from tests.common import async_fire_time_changed @@ -25,7 +36,7 @@ async def test_form(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} with RuckusAjaxApiPatchContext(), patch( @@ -37,12 +48,12 @@ async def test_form(hass: HomeAssistant) -> None: CONFIG, ) await hass.async_block_till_done() - - assert result2["type"] == "create_entry" - assert result2["title"] == DEFAULT_TITLE - assert result2["data"] == CONFIG assert len(mock_setup_entry.mock_calls) == 1 + assert result2["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result2["title"] == DEFAULT_TITLE + assert result2["data"] == CONFIG + async def test_form_invalid_auth(hass: HomeAssistant) -> None: """Test we handle invalid auth.""" @@ -58,7 +69,7 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "invalid_auth"} @@ -68,7 +79,131 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_REAUTH} + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext(): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.ABORT + assert result2["reason"] == "reauth_successful" + + +async def test_form_user_reauth_different_unique_id(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + system_info = deepcopy(DEFAULT_SYSTEM_INFO) + system_info[API_SYS_SYSINFO][API_SYS_SYSINFO_SERIAL] = "000000000" + with RuckusAjaxApiPatchContext(system_info=system_info): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_host"} + + +async def test_form_user_reauth_invalid_auth(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, + ) + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=AuthenticationError(ERROR_LOGIN_INCORRECT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_user_reauth_cannot_connect(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=entry.data, ) flows = hass.config_entries.flow.async_progress() @@ -76,20 +211,63 @@ async def test_form_user_reauth(hass: HomeAssistant) -> None: assert "flow_id" in flows[0] assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["step_id"] == "reauth_confirm" - - result2 = await hass.config_entries.flow.async_configure( - flows[0]["flow_id"], - user_input={ - CONF_HOST: "1.2.3.4", - CONF_USERNAME: "new_name", - CONF_PASSWORD: "new_pass", + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock(side_effect=ConnectionError(ERROR_CONNECT_TIMEOUT)) + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + await hass.async_block_till_done() + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_user_reauth_general_exception(hass: HomeAssistant) -> None: + """Test reauth.""" + entry = mock_config_entry() + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": config_entries.SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, }, + data=entry.data, ) - await hass.async_block_till_done() + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + assert "flow_id" in flows[0] + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_HOST: "1.2.3.4", + CONF_USERNAME: "new_name", + CONF_PASSWORD: "new_pass", + }, + ) + 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_form_cannot_connect(hass: HomeAssistant) -> None: @@ -106,45 +284,44 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} -async def test_form_unexpected_response(hass: HomeAssistant) -> None: - """Test we handle unknown error.""" +async def test_form_general_exception(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 RuckusAjaxApiPatchContext( - login_mock=AsyncMock( - side_effect=ConnectionRefusedError(ERROR_CONNECT_TEMPORARY) - ) - ): + with RuckusAjaxApiPatchContext(login_mock=AsyncMock(side_effect=Exception)): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, ) - assert result2["type"] == "form" - assert result2["errors"] == {"base": "cannot_connect"} + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "unknown"} -async def test_form_cannot_connect_unknown_serial(hass: HomeAssistant) -> None: - """Test we handle cannot connect error on invalid serial number.""" +async def test_form_unexpected_response(hass: HomeAssistant) -> None: + """Test we handle unknown error.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" - assert result["errors"] == {} - with RuckusAjaxApiPatchContext(system_info={}): + with RuckusAjaxApiPatchContext( + login_mock=AsyncMock( + side_effect=ConnectionRefusedError(ERROR_CONNECT_TEMPORARY) + ) + ): result2 = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG, ) - assert result2["type"] == "form" + assert result2["type"] == data_entry_flow.FlowResultType.FORM assert result2["errors"] == {"base": "cannot_connect"} @@ -167,7 +344,7 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) - assert result["type"] == "form" + assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["errors"] == {} result2 = await hass.config_entries.flow.async_configure( @@ -175,5 +352,5 @@ async def test_form_duplicate_error(hass: HomeAssistant) -> None: CONFIG, ) - assert result2["type"] == "abort" + assert result2["type"] == data_entry_flow.FlowResultType.ABORT assert result2["reason"] == "already_configured" From 4f0cd5589cac4ee906f75d16e1fc7fe87ae26bb3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 12:01:12 -0500 Subject: [PATCH 310/640] Bump aiohomekit to 3.0.3 (#100047) --- 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 9567ff83cea4c2..c99142da475208 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.2"], + "requirements": ["aiohomekit==3.0.3"], "zeroconf": ["_hap._tcp.local.", "_hap._udp.local."] } diff --git a/requirements_all.txt b/requirements_all.txt index 169e88edcb9982..4be58e257b8963 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -250,7 +250,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1bb45b4996ce16..b65dcc862d33ff 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -228,7 +228,7 @@ aioguardian==2022.07.0 aioharmony==0.2.10 # homeassistant.components.homekit_controller -aiohomekit==3.0.2 +aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http From a5a82b94acbf844a0f903501c70f5f2ef54de969 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Sun, 10 Sep 2023 19:09:21 +0200 Subject: [PATCH 311/640] Bump aiovodafone to 0.2.0 (#100062) bump aiovodafone to 0.2.0 --- homeassistant/components/vodafone_station/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/vodafone_station/manifest.json b/homeassistant/components/vodafone_station/manifest.json index 5470cdd684c40c..68e7665b5ac538 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.1.0"] + "requirements": ["aiovodafone==0.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4be58e257b8963..b3d7ac2a062148 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -370,7 +370,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.1.0 +aiovodafone==0.2.0 # homeassistant.components.waqi aiowaqi==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b65dcc862d33ff..5651be570219d1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -345,7 +345,7 @@ aiounifi==61 aiovlc==0.1.0 # homeassistant.components.vodafone_station -aiovodafone==0.1.0 +aiovodafone==0.2.0 # homeassistant.components.waqi aiowaqi==0.2.1 From 3238386f482c134ec9d48286f41d4be0116caac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 11 Sep 2023 02:31:11 +0900 Subject: [PATCH 312/640] Add water heater support to Airzone (#98401) Co-authored-by: J. Nick Koston --- homeassistant/components/airzone/__init__.py | 1 + homeassistant/components/airzone/entity.py | 16 ++ .../components/airzone/water_heater.py | 131 ++++++++++ tests/components/airzone/test_water_heater.py | 228 ++++++++++++++++++ 4 files changed, 376 insertions(+) create mode 100644 homeassistant/components/airzone/water_heater.py create mode 100644 tests/components/airzone/test_water_heater.py diff --git a/homeassistant/components/airzone/__init__.py b/homeassistant/components/airzone/__init__.py index de75bf03d454f7..1a54be0ac41f32 100644 --- a/homeassistant/components/airzone/__init__.py +++ b/homeassistant/components/airzone/__init__.py @@ -24,6 +24,7 @@ Platform.CLIMATE, Platform.SELECT, Platform.SENSOR, + Platform.WATER_HEATER, ] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/airzone/entity.py b/homeassistant/components/airzone/entity.py index 267cd210ff0e23..2310d5fb5a4cbf 100644 --- a/homeassistant/components/airzone/entity.py +++ b/homeassistant/components/airzone/entity.py @@ -106,6 +106,22 @@ def get_airzone_value(self, key: str) -> Any: """Return DHW value by key.""" return self.coordinator.data[AZD_HOT_WATER].get(key) + async def _async_update_dhw_params(self, params: dict[str, Any]) -> None: + """Send DHW parameters to API.""" + _params = { + API_SYSTEM_ID: 0, + **params, + } + _LOGGER.debug("update_dhw_params=%s", _params) + try: + await self.coordinator.airzone.set_dhw_parameters(_params) + except AirzoneError as error: + raise HomeAssistantError( + f"Failed to set dhw {self.name}: {error}" + ) from error + + self.coordinator.async_set_updated_data(self.coordinator.airzone.data()) + class AirzoneWebServerEntity(AirzoneEntity): """Define an Airzone WebServer entity.""" diff --git a/homeassistant/components/airzone/water_heater.py b/homeassistant/components/airzone/water_heater.py new file mode 100644 index 00000000000000..b19aa36449c9b9 --- /dev/null +++ b/homeassistant/components/airzone/water_heater.py @@ -0,0 +1,131 @@ +"""Support for the Airzone water heater.""" +from __future__ import annotations + +from typing import Any, Final + +from aioairzone.common import HotWaterOperation +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + AZD_HOT_WATER, + AZD_NAME, + AZD_OPERATION, + AZD_OPERATIONS, + AZD_TEMP, + AZD_TEMP_MAX, + AZD_TEMP_MIN, + AZD_TEMP_SET, + AZD_TEMP_UNIT, +) + +from homeassistant.components.water_heater import ( + STATE_ECO, + STATE_PERFORMANCE, + WaterHeaterEntity, + WaterHeaterEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN, TEMP_UNIT_LIB_TO_HASS +from .coordinator import AirzoneUpdateCoordinator +from .entity import AirzoneHotWaterEntity + +OPERATION_LIB_TO_HASS: Final[dict[HotWaterOperation, str]] = { + HotWaterOperation.Off: STATE_OFF, + HotWaterOperation.On: STATE_ECO, + HotWaterOperation.Powerful: STATE_PERFORMANCE, +} + +OPERATION_MODE_TO_DHW_PARAMS: Final[dict[str, dict[str, Any]]] = { + STATE_OFF: { + API_ACS_ON: 0, + }, + STATE_ECO: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + }, + STATE_PERFORMANCE: { + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + }, +} + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Add Airzone sensors from a config_entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + if AZD_HOT_WATER in coordinator.data: + async_add_entities([AirzoneWaterHeater(coordinator, entry)]) + + +class AirzoneWaterHeater(AirzoneHotWaterEntity, WaterHeaterEntity): + """Define an Airzone Water Heater.""" + + _attr_supported_features = ( + WaterHeaterEntityFeature.TARGET_TEMPERATURE + | WaterHeaterEntityFeature.ON_OFF + | WaterHeaterEntityFeature.OPERATION_MODE + ) + + def __init__( + self, + coordinator: AirzoneUpdateCoordinator, + entry: ConfigEntry, + ) -> None: + """Initialize Airzone water heater entity.""" + super().__init__(coordinator, entry) + + self._attr_name = self.get_airzone_value(AZD_NAME) + self._attr_unique_id = f"{self._attr_unique_id}_dhw" + self._attr_operation_list = [ + OPERATION_LIB_TO_HASS[operation] + for operation in self.get_airzone_value(AZD_OPERATIONS) + ] + self._attr_temperature_unit = TEMP_UNIT_LIB_TO_HASS[ + self.get_airzone_value(AZD_TEMP_UNIT) + ] + + self._async_update_attrs() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 0}) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the water heater off.""" + await self._async_update_dhw_params({API_ACS_ON: 1}) + + async def async_set_operation_mode(self, operation_mode: str) -> None: + """Set new target operation mode.""" + params = OPERATION_MODE_TO_DHW_PARAMS.get(operation_mode, {}) + await self._async_update_dhw_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_ACS_SET_POINT] = kwargs[ATTR_TEMPERATURE] + await self._async_update_dhw_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 water heater attributes.""" + self._attr_current_temperature = self.get_airzone_value(AZD_TEMP) + self._attr_current_operation = OPERATION_LIB_TO_HASS[ + self.get_airzone_value(AZD_OPERATION) + ] + self._attr_max_temp = self.get_airzone_value(AZD_TEMP_MAX) + self._attr_min_temp = self.get_airzone_value(AZD_TEMP_MIN) + self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) diff --git a/tests/components/airzone/test_water_heater.py b/tests/components/airzone/test_water_heater.py new file mode 100644 index 00000000000000..a1157192f23559 --- /dev/null +++ b/tests/components/airzone/test_water_heater.py @@ -0,0 +1,228 @@ +"""The water heater tests for the Airzone platform.""" +from unittest.mock import patch + +from aioairzone.const import ( + API_ACS_ON, + API_ACS_POWER_MODE, + API_ACS_SET_POINT, + API_DATA, + API_SYSTEM_ID, +) +from aioairzone.exceptions import AirzoneError +import pytest + +from homeassistant.components.water_heater import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_MAX_TEMP, + ATTR_MIN_TEMP, + ATTR_OPERATION_MODE, + DOMAIN as WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + SERVICE_SET_TEMPERATURE, + STATE_ECO, + STATE_PERFORMANCE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + ATTR_TEMPERATURE, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + STATE_OFF, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from .util import async_init_integration + + +async def test_airzone_create_water_heater(hass: HomeAssistant) -> None: + """Test creation of water heater.""" + + await async_init_integration(hass) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 43 + assert state.attributes[ATTR_MAX_TEMP] == 75 + assert state.attributes[ATTR_MIN_TEMP] == 30 + assert state.attributes[ATTR_TEMPERATURE] == 45 + + +async def test_airzone_water_heater_turn_on_off(hass: HomeAssistant) -> None: + """Test turning on/off.""" + + await async_init_integration(hass) + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_OFF, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_TURN_ON, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_operation(hass: HomeAssistant) -> None: + """Test setting the Operation mode.""" + + await async_init_integration(hass) + + HVAC_MOCK_1 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_1, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_OFF, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_OFF + + HVAC_MOCK_2 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 1, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_2, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_PERFORMANCE, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_PERFORMANCE + + HVAC_MOCK_3 = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_ON: 1, + API_ACS_POWER_MODE: 0, + } + } + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK_3, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_OPERATION_MODE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_OPERATION_MODE: STATE_ECO, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.state == STATE_ECO + + +async def test_airzone_water_heater_set_temp(hass: HomeAssistant) -> None: + """Test setting the target temperature.""" + + HVAC_MOCK = { + API_DATA: { + API_SYSTEM_ID: 0, + API_ACS_SET_POINT: 35, + } + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 35, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 35 + + +async def test_airzone_water_heater_set_temp_error(hass: HomeAssistant) -> None: + """Test error when setting the target temperature.""" + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + side_effect=AirzoneError, + ), pytest.raises(HomeAssistantError): + await hass.services.async_call( + WATER_HEATER_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "water_heater.airzone_dhw", + ATTR_TEMPERATURE: 80, + }, + blocking=True, + ) + + state = hass.states.get("water_heater.airzone_dhw") + assert state.attributes[ATTR_TEMPERATURE] == 45 From 3b8d99dcd85cbe1139d8e6bc80acd54101fae043 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 13:46:55 -0500 Subject: [PATCH 313/640] Add __slots__ to translation cache (#100069) --- homeassistant/helpers/translation.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index 79ac3a0c5b73b2..41ad591d878994 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -190,6 +190,8 @@ async def _async_get_component_strings( class _TranslationCache: """Cache for flattened translations.""" + __slots__ = ("hass", "loaded", "cache") + def __init__(self, hass: HomeAssistant) -> None: """Initialize the cache.""" self.hass = hass From 02a4289c6e61ba131a7be49753aa52dc7fc806f2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 14:32:40 -0500 Subject: [PATCH 314/640] Bump zeroconf to 0.104.0 (#100068) --- 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 d3fd3654997572..7d6cc32c8f1743 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.103.0"] + "requirements": ["zeroconf==0.104.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b40c0198dfe284..38aea19e10c789 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.103.0 +zeroconf==0.104.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 b3d7ac2a062148..f340aab615bb5e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2766,7 +2766,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.103.0 +zeroconf==0.104.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5651be570219d1..b70f815c1ac115 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2042,7 +2042,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.103.0 +zeroconf==0.104.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 2bda34b98ab856730149d5e1983cf4af9a1b9da1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 14:45:37 -0500 Subject: [PATCH 315/640] Bump flux_led to 1.0.4 (#100050) --- homeassistant/components/flux_led/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index d3274738f75e09..977f6eefe07dbe 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -54,5 +54,5 @@ "iot_class": "local_push", "loggers": ["flux_led"], "quality_scale": "platinum", - "requirements": ["flux-led==1.0.2"] + "requirements": ["flux-led==1.0.4"] } diff --git a/requirements_all.txt b/requirements_all.txt index f340aab615bb5e..8ea3eb021766c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -808,7 +808,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b70f815c1ac115..b3b57707332321 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -636,7 +636,7 @@ fjaraskupan==2.2.0 flipr-api==1.5.0 # homeassistant.components.flux_led -flux-led==1.0.2 +flux-led==1.0.4 # homeassistant.components.homekit # homeassistant.components.recorder From 4474face882cefe5ccda22b5fad8d37a4d9fc317 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 10 Sep 2023 22:23:18 +0200 Subject: [PATCH 316/640] Bump tibdex/github-app-token from 1.8.0 to 1.8.2 (#99434) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 5fb977f74d1ef1..a0a86d0e868dda 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@b62528385c34dbc9f38e5f4225ac829252d1ea92 + uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From 80e05716c0cfc35ba41c9c421a45b937ad6627cf Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sun, 10 Sep 2023 16:38:39 -0500 Subject: [PATCH 317/640] Bump dbus-fast to 2.2.0 (#100076) --- 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 e5df324ec02df2..8cc2a7adb65732 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.11.0", - "dbus-fast==2.0.1" + "dbus-fast==2.2.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 38aea19e10c789..74aca53df9c881 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.0.1 +dbus-fast==2.2.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index 8ea3eb021766c0..77d67f856752be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -643,7 +643,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.0.1 +dbus-fast==2.2.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b3b57707332321..054a38314a4854 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -526,7 +526,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.0.1 +dbus-fast==2.2.0 # homeassistant.components.debugpy debugpy==1.6.7 From 45fc158823b17f9875f90dae417236dab2846dae Mon Sep 17 00:00:00 2001 From: Matrix Date: Mon, 11 Sep 2023 06:31:58 +0800 Subject: [PATCH 318/640] Add yolink siren battery entity (#99310) --- homeassistant/components/yolink/sensor.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/yolink/sensor.py b/homeassistant/components/yolink/sensor.py index e4d0aa38fbee89..451b486acd21b9 100644 --- a/homeassistant/components/yolink/sensor.py +++ b/homeassistant/components/yolink/sensor.py @@ -92,6 +92,7 @@ class YoLinkSensorEntityDescription( ATTR_DEVICE_LEAK_SENSOR, ATTR_DEVICE_MOTION_SENSOR, ATTR_DEVICE_POWER_FAILURE_ALARM, + ATTR_DEVICE_SIREN, ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_TH_SENSOR, ATTR_DEVICE_VIBRATION_SENSOR, From 4ebb6bb82324e9ed05360f90e183ac7ed75e6dd7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 00:56:12 +0200 Subject: [PATCH 319/640] Add sensors to Trafikverket Camera (#100078) * Add sensors to Trafikverket Camera * Remove active * Fix test len --- .../components/trafikverket_camera/const.py | 2 +- .../components/trafikverket_camera/sensor.py | 139 ++++++++++++++++++ .../trafikverket_camera/strings.json | 20 +++ .../trafikverket_camera/test_recorder.py | 11 +- .../trafikverket_camera/test_sensor.py | 29 ++++ 5 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/sensor.py create mode 100644 tests/components/trafikverket_camera/test_sensor.py diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index 6657ab1a853d99..388df241d9955c 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ DOMAIN = "trafikverket_camera" CONF_LOCATION = "location" -PLATFORMS = [Platform.CAMERA] +PLATFORMS = [Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py new file mode 100644 index 00000000000000..eee2f353de529a --- /dev/null +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -0,0 +1,139 @@ +"""Sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import DEGREE +from homeassistant.core import HomeAssistant, callback +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 .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], StateType | datetime] + + +@dataclass +class TVCameraSensorEntityDescription( + SensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera sensor entity.""" + + +SENSOR_TYPES: tuple[TVCameraSensorEntityDescription, ...] = ( + TVCameraSensorEntityDescription( + key="direction", + translation_key="direction", + native_unit_of_measurement=DEGREE, + icon="mdi:sign-direction", + value_fn=lambda data: data.data.direction, + ), + TVCameraSensorEntityDescription( + key="modified", + translation_key="modified", + icon="mdi:camera-retake-outline", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.modified, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="photo_time", + translation_key="photo_time", + icon="mdi:camera-timer", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda data: data.data.phototime, + ), + TVCameraSensorEntityDescription( + key="photo_url", + translation_key="photo_url", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.photourl, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="status", + translation_key="status", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.status, + entity_registry_enabled_default=False, + ), + TVCameraSensorEntityDescription( + key="camera_type", + translation_key="camera_type", + icon="mdi:camera-iris", + value_fn=lambda data: data.data.camera_type, + entity_registry_enabled_default=False, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + TrafikverketCameraSensor(coordinator, entry.entry_id, entry.title, description) + for description in SENSOR_TYPES + ) + + +class TrafikverketCameraSensor( + CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity +): + """Representation of a Trafikverket Camera Sensor.""" + + entity_description: TVCameraSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + name: str, + entity_description: TVCameraSensorEntityDescription, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index c128f7729bcc7e..27360100a29d47 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -46,6 +46,26 @@ } } } + }, + "sensor": { + "direction": { + "name": "Direction" + }, + "modified": { + "name": "Modified" + }, + "photo_time": { + "name": "Photo time" + }, + "photo_url": { + "name": "Photo url" + }, + "status": { + "name": "Status" + }, + "camera_type": { + "name": "Camera type" + } } } } diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 021433b33e7348..5dff358d974b12 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -16,6 +16,7 @@ async def test_exclude_attributes( recorder_mock: Recorder, + entity_registry_enabled_by_default: None, hass: HomeAssistant, load_int: ConfigEntry, monkeypatch: pytest.MonkeyPatch, @@ -37,10 +38,12 @@ async def test_exclude_attributes( None, hass.states.async_entity_ids(), ) - assert len(states) == 1 + assert len(states) == 7 assert states.get("camera.test_location") for entity_states in states.values(): for state in entity_states: - assert "location" not in state.attributes - assert "description" not in state.attributes - assert "type" in state.attributes + if state.entity_id == "camera.test_location": + assert "location" not in state.attributes + assert "description" not in state.attributes + assert "type" in state.attributes + break diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py new file mode 100644 index 00000000000000..cc97f4dbdcbf75 --- /dev/null +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -0,0 +1,29 @@ +"""The test for the sensibo select platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera sensor.""" + + state = hass.states.get("sensor.test_location_direction") + assert state.state == "180" + state = hass.states.get("sensor.test_location_modified") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_time") + assert state.state == "2022-04-04T04:04:04+00:00" + state = hass.states.get("sensor.test_location_photo_url") + assert state.state == "https://www.testurl.com/test_photo.jpg" + state = hass.states.get("sensor.test_location_status") + assert state.state == "Running" + state = hass.states.get("sensor.test_location_camera_type") + assert state.state == "Road" From 0fae65abde2c7f64a67de298255f496506e4bd9d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:10:59 +0200 Subject: [PATCH 320/640] Fix missed name to translation key in Sensibo (#100080) --- homeassistant/components/sensibo/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensibo/sensor.py b/homeassistant/components/sensibo/sensor.py index 547504d7889265..f6d62d79dff3ce 100644 --- a/homeassistant/components/sensibo/sensor.py +++ b/homeassistant/components/sensibo/sensor.py @@ -232,7 +232,7 @@ class SensiboDeviceSensorEntityDescription( key="ethanol", native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, state_class=SensorStateClass.MEASUREMENT, - name="Ethanol", + translation_key="ethanol", value_fn=lambda data: data.etoh, extra_fn=None, ), From 954293f77ee5dfdc905ee7c1b265c97cb3520465 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:12:19 +0200 Subject: [PATCH 321/640] Add binary sensors to Trafikverket Camera (#100082) --- .../trafikverket_camera/binary_sensor.py | 97 +++++++++++++++++++ .../components/trafikverket_camera/const.py | 2 +- .../trafikverket_camera/strings.json | 5 + .../trafikverket_camera/test_binary_sensor.py | 20 ++++ .../trafikverket_camera/test_recorder.py | 2 +- 5 files changed, 124 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/binary_sensor.py create mode 100644 tests/components/trafikverket_camera/test_binary_sensor.py diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py new file mode 100644 index 00000000000000..bfbecf707bffeb --- /dev/null +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -0,0 +1,97 @@ +"""Binary sensor platform for Trafikverket Camera integration.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +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 + +from .const import DOMAIN +from .coordinator import CameraData, TVDataUpdateCoordinator + +PARALLEL_UPDATES = 0 + + +@dataclass +class DeviceBaseEntityDescriptionMixin: + """Mixin for required Trafikverket Camera base description keys.""" + + value_fn: Callable[[CameraData], bool | None] + + +@dataclass +class TVCameraSensorEntityDescription( + BinarySensorEntityDescription, DeviceBaseEntityDescriptionMixin +): + """Describes Trafikverket Camera binary sensor entity.""" + + +BINARY_SENSOR_TYPE = TVCameraSensorEntityDescription( + key="active", + translation_key="active", + icon="mdi:camera-outline", + value_fn=lambda data: data.data.active, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up Trafikverket Camera binary sensor platform.""" + + coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + [ + TrafikverketCameraBinarySensor( + coordinator, entry.entry_id, entry.title, BINARY_SENSOR_TYPE + ) + ] + ) + + +class TrafikverketCameraBinarySensor( + CoordinatorEntity[TVDataUpdateCoordinator], BinarySensorEntity +): + """Representation of a Trafikverket Camera binary sensor.""" + + entity_description: TVCameraSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + name: str, + entity_description: TVCameraSensorEntityDescription, + ) -> None: + """Initiate Trafikverket Camera Binary sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + self._attr_unique_id = f"{entry_id}-{entity_description.key}" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, entry_id)}, + manufacturer="Trafikverket", + model="v1.0", + name=name, + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/const.py b/homeassistant/components/trafikverket_camera/const.py index 388df241d9955c..ff40d1bbc919e5 100644 --- a/homeassistant/components/trafikverket_camera/const.py +++ b/homeassistant/components/trafikverket_camera/const.py @@ -3,7 +3,7 @@ DOMAIN = "trafikverket_camera" CONF_LOCATION = "location" -PLATFORMS = [Platform.CAMERA, Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CAMERA, Platform.SENSOR] ATTRIBUTION = "Data provided by Trafikverket" ATTR_DESCRIPTION = "description" diff --git a/homeassistant/components/trafikverket_camera/strings.json b/homeassistant/components/trafikverket_camera/strings.json index 27360100a29d47..651225934cd587 100644 --- a/homeassistant/components/trafikverket_camera/strings.json +++ b/homeassistant/components/trafikverket_camera/strings.json @@ -47,6 +47,11 @@ } } }, + "binary_sensor": { + "active": { + "name": "Active" + } + }, "sensor": { "direction": { "name": "Direction" diff --git a/tests/components/trafikverket_camera/test_binary_sensor.py b/tests/components/trafikverket_camera/test_binary_sensor.py new file mode 100644 index 00000000000000..6f7eb54028917e --- /dev/null +++ b/tests/components/trafikverket_camera/test_binary_sensor.py @@ -0,0 +1,20 @@ +"""The test for the Trafikverket binary sensor platform.""" +from __future__ import annotations + +from pytrafikverket.trafikverket_camera import CameraInfo + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + + +async def test_sensor( + hass: HomeAssistant, + entity_registry_enabled_by_default: None, + load_int: ConfigEntry, + get_camera: CameraInfo, +) -> None: + """Test the Trafikverket Camera binary sensor.""" + + state = hass.states.get("binary_sensor.test_location_active") + assert state.state == STATE_ON diff --git a/tests/components/trafikverket_camera/test_recorder.py b/tests/components/trafikverket_camera/test_recorder.py index 5dff358d974b12..b9add7ae4830bd 100644 --- a/tests/components/trafikverket_camera/test_recorder.py +++ b/tests/components/trafikverket_camera/test_recorder.py @@ -38,7 +38,7 @@ async def test_exclude_attributes( None, hass.states.async_entity_ids(), ) - assert len(states) == 7 + assert len(states) == 8 assert states.get("camera.test_location") for entity_states in states.values(): for state in entity_states: From 73a695d857fcfbaa90587610896b8e89e3d99bbf Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 11 Sep 2023 01:22:33 +0200 Subject: [PATCH 322/640] Fix incorrect docstring in TV Camera sensor test (#100083) --- tests/components/trafikverket_camera/test_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/trafikverket_camera/test_sensor.py b/tests/components/trafikverket_camera/test_sensor.py index cc97f4dbdcbf75..581fed1d28960e 100644 --- a/tests/components/trafikverket_camera/test_sensor.py +++ b/tests/components/trafikverket_camera/test_sensor.py @@ -1,4 +1,4 @@ -"""The test for the sensibo select platform.""" +"""The test for the Trafikverket sensor platform.""" from __future__ import annotations from pytrafikverket.trafikverket_camera import CameraInfo From 6c45f43c5d2f69c535b2da7108e3cd484e628def Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Mon, 11 Sep 2023 01:24:57 +0200 Subject: [PATCH 323/640] Renson number entity (#99358) * Starting number sensor * Filter change config * Add translation to number entity * add number entity to .coveragerc * Moved has_entity_name to description + changed name of entity * Add self.coordinator.async_request_refresh() after changing value * Add device calss and unit of measurement to number entity --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/number.py | 84 ++++++++++++++++++++ homeassistant/components/renson/strings.json | 5 ++ 4 files changed, 91 insertions(+) create mode 100644 homeassistant/components/renson/number.py diff --git a/.coveragerc b/.coveragerc index 23236891807fef..e932be670cb0a5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1011,6 +1011,7 @@ omit = homeassistant/components/renson/sensor.py homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py + homeassistant/components/renson/number.py homeassistant/components/raspyrfm/* homeassistant/components/recollect_waste/sensor.py homeassistant/components/recorder/repack.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index dbc0468a11adef..7ce143d8a214f1 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -22,6 +22,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, Platform.FAN, + Platform.NUMBER, Platform.SENSOR, ] diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py new file mode 100644 index 00000000000000..bf33b75c9e36e8 --- /dev/null +++ b/homeassistant/components/renson/number.py @@ -0,0 +1,84 @@ +"""Platform to control a Renson ventilation unit.""" +from __future__ import annotations + +import logging + +from renson_endura_delta.field_enum import FILTER_PRESET_FIELD, DataType +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.number import ( + NumberDeviceClass, + NumberEntity, + NumberEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, UnitOfTime +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator +from .const import DOMAIN +from .entity import RensonEntity + +_LOGGER = logging.getLogger(__name__) + + +RENSON_NUMBER_DESCRIPTION = NumberEntityDescription( + key="filter_change", + translation_key="filter_change", + icon="mdi:filter", + native_step=1, + native_min_value=0, + native_max_value=360, + entity_category=EntityCategory.CONFIG, + has_entity_name=True, + device_class=NumberDeviceClass.DURATION, + native_unit_of_measurement=UnitOfTime.DAYS, +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson number platform.""" + + api: RensonVentilation = hass.data[DOMAIN][config_entry.entry_id].api + coordinator: RensonCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ].coordinator + + async_add_entities([RensonNumber(RENSON_NUMBER_DESCRIPTION, api, coordinator)]) + + +class RensonNumber(RensonEntity, NumberEntity): + """Representation of the Renson number platform.""" + + def __init__( + self, + description: NumberEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize the Renson number.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._attr_native_value = self.api.parse_value( + self.api.get_field_value(self.coordinator.data, FILTER_PRESET_FIELD.name), + DataType.NUMERIC, + ) + + super()._handle_coordinator_update() + + async def async_set_native_value(self, value: float) -> None: + """Update the current value.""" + + await self.hass.async_add_executor_job(self.api.set_filter_days, value) + + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 20db9e788b8c77..1a4829c2da9dbe 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,11 @@ } }, "entity": { + "number": { + "filter_change": { + "name": "Filter clean/replacement" + } + }, "binary_sensor": { "frost_protection_active": { "name": "Frost protection active" From 8beace265b44afa3475dffe2e29406969d83aacd Mon Sep 17 00:00:00 2001 From: Michael Arthur Date: Mon, 11 Sep 2023 11:30:25 +1200 Subject: [PATCH 324/640] Add unit tests for sensors Electric Kiwi (#97723) * add unit tests for sensors * newline long strings * unit test check and move time * rename entry to entity Co-authored-by: Joost Lekkerkerker * add types to test Co-authored-by: Joost Lekkerkerker * fix newlined f strings * remove if statement * add some more explaination * Update datetime Co-authored-by: Robert Resch * Simpler time update Co-authored-by: Robert Resch * add missing datetime import * Update docustring - grammar Co-authored-by: Martin Hjelmare * address comments and issues raised * address docstrings too long * Fix docstring --------- Co-authored-by: Joost Lekkerkerker Co-authored-by: Robert Resch Co-authored-by: Martin Hjelmare --- .coveragerc | 1 - .../components/electric_kiwi/select.py | 10 +- .../components/electric_kiwi/sensor.py | 9 +- tests/components/electric_kiwi/conftest.py | 77 +++++- .../electric_kiwi/fixtures/get_hop.json | 16 ++ .../electric_kiwi/fixtures/hop_intervals.json | 249 ++++++++++++++++++ .../electric_kiwi/test_config_flow.py | 18 +- tests/components/electric_kiwi/test_sensor.py | 83 ++++++ 8 files changed, 442 insertions(+), 21 deletions(-) create mode 100644 tests/components/electric_kiwi/fixtures/get_hop.json create mode 100644 tests/components/electric_kiwi/fixtures/hop_intervals.json create mode 100644 tests/components/electric_kiwi/test_sensor.py diff --git a/.coveragerc b/.coveragerc index e932be670cb0a5..4df91b250ed70d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -277,7 +277,6 @@ omit = homeassistant/components/electric_kiwi/__init__.py homeassistant/components/electric_kiwi/api.py homeassistant/components/electric_kiwi/oauth2.py - homeassistant/components/electric_kiwi/sensor.py homeassistant/components/electric_kiwi/coordinator.py homeassistant/components/electric_kiwi/select.py homeassistant/components/eliqonline/sensor.py diff --git a/homeassistant/components/electric_kiwi/select.py b/homeassistant/components/electric_kiwi/select.py index 9d883c72d1ee56..eb8aaac8c2faba 100644 --- a/homeassistant/components/electric_kiwi/select.py +++ b/homeassistant/components/electric_kiwi/select.py @@ -50,7 +50,10 @@ def __init__( ) -> None: """Initialise the HOP selection entity.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description self.values_dict = coordinator.get_hop_options() self._attr_options = list(self.values_dict) @@ -58,7 +61,10 @@ def __init__( @property def current_option(self) -> str | None: """Return the currently selected option.""" - return f"{self.coordinator.data.start.start_time} - {self.coordinator.data.end.end_time}" + return ( + f"{self.coordinator.data.start.start_time}" + f" - {self.coordinator.data.end.end_time}" + ) async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/electric_kiwi/sensor.py b/homeassistant/components/electric_kiwi/sensor.py index 8c983b92dd5f14..8017bbf006e055 100644 --- a/homeassistant/components/electric_kiwi/sensor.py +++ b/homeassistant/components/electric_kiwi/sensor.py @@ -62,7 +62,7 @@ def _check_and_move_time(hop: Hop, time: str) -> datetime: return date_time -HOP_SENSOR_TYPE: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( +HOP_SENSOR_TYPES: tuple[ElectricKiwiHOPSensorEntityDescription, ...] = ( ElectricKiwiHOPSensorEntityDescription( key=ATTR_EK_HOP_START, translation_key="hopfreepowerstart", @@ -85,7 +85,7 @@ async def async_setup_entry( hop_coordinator: ElectricKiwiHOPDataCoordinator = hass.data[DOMAIN][entry.entry_id] hop_entities = [ ElectricKiwiHOPEntity(hop_coordinator, description) - for description in HOP_SENSOR_TYPE + for description in HOP_SENSOR_TYPES ] async_add_entities(hop_entities) @@ -107,7 +107,10 @@ def __init__( """Entity object for Electric Kiwi sensor.""" super().__init__(coordinator) - self._attr_unique_id = f"{coordinator._ek_api.customer_number}_{coordinator._ek_api.connection_id}_{description.key}" + self._attr_unique_id = ( + f"{coordinator._ek_api.customer_number}" + f"_{coordinator._ek_api.connection_id}_{description.key}" + ) self.entity_description = description @property diff --git a/tests/components/electric_kiwi/conftest.py b/tests/components/electric_kiwi/conftest.py index 525f5742382973..f7e60e975f8fae 100644 --- a/tests/components/electric_kiwi/conftest.py +++ b/tests/components/electric_kiwi/conftest.py @@ -1,9 +1,12 @@ """Define fixtures for electric kiwi tests.""" from __future__ import annotations -from collections.abc import Generator +from collections.abc import Awaitable, Callable, Generator +from time import time from unittest.mock import AsyncMock, patch +import zoneinfo +from electrickiwi_api.model import Hop, HopIntervals import pytest from homeassistant.components.application_credentials import ( @@ -14,12 +17,17 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_value_fixture CLIENT_ID = "1234" CLIENT_SECRET = "5678" REDIRECT_URI = "https://example.com/auth/external/callback" +TZ_NAME = "Pacific/Auckland" +TIMEZONE = zoneinfo.ZoneInfo(TZ_NAME) +YieldFixture = Generator[AsyncMock, None, None] +ComponentSetup = Callable[[], Awaitable[bool]] + @pytest.fixture(autouse=True) async def request_setup(current_request_with_host) -> None: @@ -28,14 +36,23 @@ async def request_setup(current_request_with_host) -> None: @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), - ) +def component_setup( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> ComponentSetup: + """Fixture for setting up the integration.""" + + async def _setup_func() -> bool: + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + DOMAIN, + ) + config_entry.add_to_hass(hass) + return await hass.config_entries.async_setup(config_entry.entry_id) + + return _setup_func @pytest.fixture(name="config_entry") @@ -45,12 +62,18 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: title="Electric Kiwi", domain=DOMAIN, data={ - "id": "mock_user", + "id": "12345", "auth_implementation": DOMAIN, + "token": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "expires_at": time() + 60, + }, }, unique_id=DOMAIN, ) - entry.add_to_hass(hass) return entry @@ -61,3 +84,33 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]: "homeassistant.components.electric_kiwi.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +@pytest.fixture(name="ek_auth") +def electric_kiwi_auth() -> YieldFixture: + """Patch access to electric kiwi access token.""" + with patch( + "homeassistant.components.electric_kiwi.api.AsyncConfigEntryAuth" + ) as mock_auth: + mock_auth.return_value.async_get_access_token = AsyncMock("auth_token") + yield mock_auth + + +@pytest.fixture(name="ek_api") +def ek_api() -> YieldFixture: + """Mock ek api and return values.""" + with patch( + "homeassistant.components.electric_kiwi.ElectricKiwiApi", autospec=True + ) as mock_ek_api: + mock_ek_api.return_value.customer_number = 123456 + mock_ek_api.return_value.connection_id = 123456 + mock_ek_api.return_value.set_active_session.return_value = None + mock_ek_api.return_value.get_hop_intervals.return_value = ( + HopIntervals.from_dict( + load_json_value_fixture("hop_intervals.json", DOMAIN) + ) + ) + mock_ek_api.return_value.get_hop.return_value = Hop.from_dict( + load_json_value_fixture("get_hop.json", DOMAIN) + ) + yield mock_ek_api diff --git a/tests/components/electric_kiwi/fixtures/get_hop.json b/tests/components/electric_kiwi/fixtures/get_hop.json new file mode 100644 index 00000000000000..d29825391e906d --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/get_hop.json @@ -0,0 +1,16 @@ +{ + "data": { + "connection_id": "3", + "customer_number": 1000001, + "end": { + "end_time": "5:00 PM", + "interval": "34" + }, + "start": { + "start_time": "4:00 PM", + "interval": "33" + }, + "type": "hop_customer" + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/fixtures/hop_intervals.json b/tests/components/electric_kiwi/fixtures/hop_intervals.json new file mode 100644 index 00000000000000..15ecc174f13208 --- /dev/null +++ b/tests/components/electric_kiwi/fixtures/hop_intervals.json @@ -0,0 +1,249 @@ +{ + "data": { + "hop_duration": "60", + "type": "hop_intervals", + "intervals": { + "1": { + "active": 1, + "end_time": "1:00 AM", + "start_time": "12:00 AM" + }, + "2": { + "active": 1, + "end_time": "1:30 AM", + "start_time": "12:30 AM" + }, + "3": { + "active": 1, + "end_time": "2:00 AM", + "start_time": "1:00 AM" + }, + "4": { + "active": 1, + "end_time": "2:30 AM", + "start_time": "1:30 AM" + }, + "5": { + "active": 1, + "end_time": "3:00 AM", + "start_time": "2:00 AM" + }, + "6": { + "active": 1, + "end_time": "3:30 AM", + "start_time": "2:30 AM" + }, + "7": { + "active": 1, + "end_time": "4:00 AM", + "start_time": "3:00 AM" + }, + "8": { + "active": 1, + "end_time": "4:30 AM", + "start_time": "3:30 AM" + }, + "9": { + "active": 1, + "end_time": "5:00 AM", + "start_time": "4:00 AM" + }, + "10": { + "active": 1, + "end_time": "5:30 AM", + "start_time": "4:30 AM" + }, + "11": { + "active": 1, + "end_time": "6:00 AM", + "start_time": "5:00 AM" + }, + "12": { + "active": 1, + "end_time": "6:30 AM", + "start_time": "5:30 AM" + }, + "13": { + "active": 1, + "end_time": "7:00 AM", + "start_time": "6:00 AM" + }, + "14": { + "active": 1, + "end_time": "7:30 AM", + "start_time": "6:30 AM" + }, + "15": { + "active": 1, + "end_time": "8:00 AM", + "start_time": "7:00 AM" + }, + "16": { + "active": 1, + "end_time": "8:30 AM", + "start_time": "7:30 AM" + }, + "17": { + "active": 1, + "end_time": "9:00 AM", + "start_time": "8:00 AM" + }, + "18": { + "active": 1, + "end_time": "9:30 AM", + "start_time": "8:30 AM" + }, + "19": { + "active": 1, + "end_time": "10:00 AM", + "start_time": "9:00 AM" + }, + "20": { + "active": 1, + "end_time": "10:30 AM", + "start_time": "9:30 AM" + }, + "21": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 AM" + }, + "22": { + "active": 1, + "end_time": "11:30 AM", + "start_time": "10:30 AM" + }, + "23": { + "active": 1, + "end_time": "12:00 PM", + "start_time": "11:00 AM" + }, + "24": { + "active": 1, + "end_time": "12:30 PM", + "start_time": "11:30 AM" + }, + "25": { + "active": 1, + "end_time": "1:00 PM", + "start_time": "12:00 PM" + }, + "26": { + "active": 1, + "end_time": "1:30 PM", + "start_time": "12:30 PM" + }, + "27": { + "active": 1, + "end_time": "2:00 PM", + "start_time": "1:00 PM" + }, + "28": { + "active": 1, + "end_time": "2:30 PM", + "start_time": "1:30 PM" + }, + "29": { + "active": 1, + "end_time": "3:00 PM", + "start_time": "2:00 PM" + }, + "30": { + "active": 1, + "end_time": "3:30 PM", + "start_time": "2:30 PM" + }, + "31": { + "active": 1, + "end_time": "4:00 PM", + "start_time": "3:00 PM" + }, + "32": { + "active": 1, + "end_time": "4:30 PM", + "start_time": "3:30 PM" + }, + "33": { + "active": 1, + "end_time": "5:00 PM", + "start_time": "4:00 PM" + }, + "34": { + "active": 1, + "end_time": "5:30 PM", + "start_time": "4:30 PM" + }, + "35": { + "active": 1, + "end_time": "6:00 PM", + "start_time": "5:00 PM" + }, + "36": { + "active": 1, + "end_time": "6:30 PM", + "start_time": "5:30 PM" + }, + "37": { + "active": 1, + "end_time": "7:00 PM", + "start_time": "6:00 PM" + }, + "38": { + "active": 1, + "end_time": "7:30 PM", + "start_time": "6:30 PM" + }, + "39": { + "active": 1, + "end_time": "8:00 PM", + "start_time": "7:00 PM" + }, + "40": { + "active": 1, + "end_time": "8:30 PM", + "start_time": "7:30 PM" + }, + "41": { + "active": 1, + "end_time": "9:00 PM", + "start_time": "8:00 PM" + }, + "42": { + "active": 1, + "end_time": "9:30 PM", + "start_time": "8:30 PM" + }, + "43": { + "active": 1, + "end_time": "10:00 PM", + "start_time": "9:00 PM" + }, + "44": { + "active": 1, + "end_time": "10:30 PM", + "start_time": "9:30 PM" + }, + "45": { + "active": 1, + "end_time": "11:00 AM", + "start_time": "10:00 PM" + }, + "46": { + "active": 1, + "end_time": "11:30 PM", + "start_time": "10:30 PM" + }, + "47": { + "active": 1, + "end_time": "12:00 AM", + "start_time": "11:00 PM" + }, + "48": { + "active": 1, + "end_time": "12:30 AM", + "start_time": "11:30 PM" + } + } + }, + "status": 1 +} diff --git a/tests/components/electric_kiwi/test_config_flow.py b/tests/components/electric_kiwi/test_config_flow.py index 51d00722341f53..1199c3e555a537 100644 --- a/tests/components/electric_kiwi/test_config_flow.py +++ b/tests/components/electric_kiwi/test_config_flow.py @@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component from .conftest import CLIENT_ID, CLIENT_SECRET, REDIRECT_URI @@ -31,6 +32,17 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup application credentials component.""" + await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + async def test_config_flow_no_credentials(hass: HomeAssistant) -> None: """Test config flow base case with no credentials registered.""" result = await hass.config_entries.flow.async_init( @@ -45,12 +57,12 @@ async def test_full_flow( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, current_request_with_host: None, - setup_credentials, + setup_credentials: None, mock_setup_entry: AsyncMock, ) -> None: """Check full flow.""" await async_import_client_credential( - hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET), "imported-cred" + hass, DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET) ) result = await hass.config_entries.flow.async_init( @@ -103,7 +115,7 @@ async def test_existing_entry( config_entry: MockConfigEntry, ) -> None: """Check existing entry.""" - + config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 result = await hass.config_entries.flow.async_init( diff --git a/tests/components/electric_kiwi/test_sensor.py b/tests/components/electric_kiwi/test_sensor.py new file mode 100644 index 00000000000000..ef2687353344d2 --- /dev/null +++ b/tests/components/electric_kiwi/test_sensor.py @@ -0,0 +1,83 @@ +"""The tests for Electric Kiwi sensors.""" + + +from datetime import UTC, datetime +from unittest.mock import AsyncMock, Mock + +from freezegun import freeze_time +import pytest + +from homeassistant.components.electric_kiwi.const import ATTRIBUTION +from homeassistant.components.electric_kiwi.sensor import _check_and_move_time +from homeassistant.components.sensor import SensorDeviceClass +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import EntityRegistry +import homeassistant.util.dt as dt_util + +from .conftest import TIMEZONE, ComponentSetup, YieldFixture + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("sensor", "sensor_state"), + [ + ("sensor.hour_of_free_power_start", "4:00 PM"), + ("sensor.hour_of_free_power_end", "5:00 PM"), + ], +) +async def test_hop_sensors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + ek_api: YieldFixture, + ek_auth: YieldFixture, + entity_registry: EntityRegistry, + component_setup: ComponentSetup, + sensor: str, + sensor_state: str, +) -> None: + """Test HOP sensors for the Electric Kiwi integration. + + This time (note no day is given, it's only a time) is fed + from the Electric Kiwi API. if the API returns 4:00 PM, the + sensor state should be set to today at 4pm or if now is past 4pm, + then tomorrow at 4pm. + """ + assert await component_setup() + assert config_entry.state is ConfigEntryState.LOADED + + entity = entity_registry.async_get(sensor) + assert entity + + state = hass.states.get(sensor) + assert state + + api = ek_api(Mock()) + hop_data = await api.get_hop() + + value = _check_and_move_time(hop_data, sensor_state) + + value = value.astimezone(UTC) + assert state.state == value.isoformat(timespec="seconds") + assert state.attributes.get(ATTR_ATTRIBUTION) == ATTRIBUTION + assert state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.TIMESTAMP + + +async def test_check_and_move_time(ek_api: AsyncMock) -> None: + """Test correct time is returned depending on time of day.""" + hop = await ek_api(Mock()).get_hop() + + test_time = datetime(2023, 6, 21, 18, 0, 0, tzinfo=TIMEZONE) + dt_util.set_default_time_zone(TIMEZONE) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-22 16:00:00+12:00" + + test_time = test_time.replace(hour=10) + + with freeze_time(test_time): + value = _check_and_move_time(hop, "4:00 PM") + assert str(value) == "2023-06-21 16:00:00+12:00" From 0fb678abfc35fa07f7b242423414bfb57e7c8c75 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 11 Sep 2023 08:49:10 +0200 Subject: [PATCH 325/640] Remove Comelit alarm data retrieval (#100067) fix: remove alarm data retrieval --- homeassistant/components/comelit/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/comelit/coordinator.py b/homeassistant/components/comelit/coordinator.py index beb7266c403c35..1affd5046fec81 100644 --- a/homeassistant/components/comelit/coordinator.py +++ b/homeassistant/components/comelit/coordinator.py @@ -44,7 +44,6 @@ async def _async_update_data(self) -> dict[str, Any]: raise ConfigEntryAuthFailed devices_data = await self.api.get_all_devices() - alarm_data = await self.api.get_alarm_config() await self.api.logout() - return devices_data | alarm_data + return devices_data From f121e891fd753a9b134a498fb549371ee9523d86 Mon Sep 17 00:00:00 2001 From: Greig Sheridan Date: Mon, 11 Sep 2023 19:16:21 +1200 Subject: [PATCH 326/640] Remove duplicated word in enphase description text (#100098) --- homeassistant/components/enphase_envoy/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/enphase_envoy/strings.json b/homeassistant/components/enphase_envoy/strings.json index ae0ac31413caad..92eca38ef20288 100644 --- a/homeassistant/components/enphase_envoy/strings.json +++ b/homeassistant/components/enphase_envoy/strings.json @@ -3,7 +3,7 @@ "flow_title": "{serial} ({host})", "step": { "user": { - "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models models, enter username `installer` without a password.", + "description": "For firmware version 7.0 and later, enter the Enphase cloud credentials, for older models, enter username `installer` without a password.", "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", From 43fe8d16c30199eae54e8fb329b284268a700346 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 09:32:43 +0200 Subject: [PATCH 327/640] Use shorthand attributes in ZAMG (#99925) Co-authored-by: Robert Resch --- homeassistant/components/zamg/weather.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zamg/weather.py b/homeassistant/components/zamg/weather.py index ff98496bd40edd..98e08106dca11c 100644 --- a/homeassistant/components/zamg/weather.py +++ b/homeassistant/components/zamg/weather.py @@ -32,6 +32,10 @@ class ZamgWeather(CoordinatorEntity, WeatherEntity): """Representation of a weather condition.""" _attr_attribution = ATTRIBUTION + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.HPA + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS def __init__( self, coordinator: ZamgDataUpdateCoordinator, name: str, station_id: str @@ -48,16 +52,6 @@ def __init__( configuration_url=MANUFACTURER_URL, name=coordinator.name, ) - # set units of ZAMG API - self._attr_native_temperature_unit = UnitOfTemperature.CELSIUS - self._attr_native_pressure_unit = UnitOfPressure.HPA - self._attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND - self._attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS - - @property - def condition(self) -> str | None: - """Return the current condition.""" - return None @property def native_temperature(self) -> float | None: From eb0099dee80971cb4619852d5e03964795f18b9e Mon Sep 17 00:00:00 2001 From: Rami Mosleh Date: Mon, 11 Sep 2023 10:36:55 +0300 Subject: [PATCH 328/640] Move smtp constants to const.py (#99542) --- homeassistant/components/smtp/__init__.py | 5 ---- homeassistant/components/smtp/const.py | 22 ++++++++++++++ homeassistant/components/smtp/notify.py | 35 ++++++++++++----------- tests/components/smtp/test_notify.py | 2 +- 4 files changed, 41 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/smtp/const.py diff --git a/homeassistant/components/smtp/__init__.py b/homeassistant/components/smtp/__init__.py index abf54efdd9dcaf..5e7fb41c2127a1 100644 --- a/homeassistant/components/smtp/__init__.py +++ b/homeassistant/components/smtp/__init__.py @@ -1,6 +1 @@ """The smtp component.""" - -from homeassistant.const import Platform - -DOMAIN = "smtp" -PLATFORMS = [Platform.NOTIFY] diff --git a/homeassistant/components/smtp/const.py b/homeassistant/components/smtp/const.py new file mode 100644 index 00000000000000..1fa077a24fb8a6 --- /dev/null +++ b/homeassistant/components/smtp/const.py @@ -0,0 +1,22 @@ +"""Constants for the smtp integration.""" + +from typing import Final + +DOMAIN: Final = "smtp" + +ATTR_IMAGES: Final = "images" # optional embedded image file attachments +ATTR_HTML: Final = "html" +ATTR_SENDER_NAME: Final = "sender_name" + +CONF_ENCRYPTION: Final = "encryption" +CONF_DEBUG: Final = "debug" +CONF_SERVER: Final = "server" +CONF_SENDER_NAME: Final = "sender_name" + +DEFAULT_HOST: Final = "localhost" +DEFAULT_PORT: Final = 587 +DEFAULT_TIMEOUT: Final = 5 +DEFAULT_DEBUG: Final = False +DEFAULT_ENCRYPTION: Final = "starttls" + +ENCRYPTION_OPTIONS: Final = ["tls", "starttls", "none"] diff --git a/homeassistant/components/smtp/notify.py b/homeassistant/components/smtp/notify.py index 7037c239db3945..6836a0b9f6b670 100644 --- a/homeassistant/components/smtp/notify.py +++ b/homeassistant/components/smtp/notify.py @@ -28,6 +28,7 @@ CONF_TIMEOUT, CONF_USERNAME, CONF_VERIFY_SSL, + Platform, ) from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -36,25 +37,25 @@ import homeassistant.util.dt as dt_util from homeassistant.util.ssl import client_context -from . import DOMAIN, PLATFORMS - -_LOGGER = logging.getLogger(__name__) - -ATTR_IMAGES = "images" # optional embedded image file attachments -ATTR_HTML = "html" - -CONF_ENCRYPTION = "encryption" -CONF_DEBUG = "debug" -CONF_SERVER = "server" -CONF_SENDER_NAME = "sender_name" +from .const import ( + ATTR_HTML, + ATTR_IMAGES, + CONF_DEBUG, + CONF_ENCRYPTION, + CONF_SENDER_NAME, + CONF_SERVER, + DEFAULT_DEBUG, + DEFAULT_ENCRYPTION, + DEFAULT_HOST, + DEFAULT_PORT, + DEFAULT_TIMEOUT, + DOMAIN, + ENCRYPTION_OPTIONS, +) -DEFAULT_HOST = "localhost" -DEFAULT_PORT = 587 -DEFAULT_TIMEOUT = 5 -DEFAULT_DEBUG = False -DEFAULT_ENCRYPTION = "starttls" +PLATFORMS = [Platform.NOTIFY] -ENCRYPTION_OPTIONS = ["tls", "starttls", "none"] +_LOGGER = logging.getLogger(__name__) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index aca30c8eac7a26..86a21c754ed3f0 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -6,7 +6,7 @@ from homeassistant import config as hass_config import homeassistant.components.notify as notify -from homeassistant.components.smtp import DOMAIN +from homeassistant.components.smtp.const import DOMAIN from homeassistant.components.smtp.notify import MailNotificationService from homeassistant.const import SERVICE_RELOAD from homeassistant.core import HomeAssistant From 20d0ebe3fab9528cb9806ec9821447f560c1e5e9 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Mon, 11 Sep 2023 10:58:33 +0200 Subject: [PATCH 329/640] Add TYPE_CHECKING condition on type assertions for mqtt (#100107) Add TYPE_CHECKING condition on type assertions --- homeassistant/components/mqtt/__init__.py | 5 +++-- homeassistant/components/mqtt/camera.py | 4 +++- homeassistant/components/mqtt/config_flow.py | 8 +++++--- homeassistant/components/mqtt/debug_info.py | 12 ++++++------ homeassistant/components/mqtt/device_trigger.py | 8 +++++--- homeassistant/components/mqtt/diagnostics.py | 5 +++-- homeassistant/components/mqtt/discovery.py | 5 +++-- homeassistant/components/mqtt/image.py | 5 +++-- homeassistant/components/mqtt/mixins.py | 8 +++++--- homeassistant/components/mqtt/subscription.py | 5 +++-- 10 files changed, 39 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 50ab9dec36fbb7..5b5c39e6831c7f 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,7 +5,7 @@ from collections.abc import Callable from datetime import datetime import logging -from typing import Any, TypeVar, cast +from typing import TYPE_CHECKING, Any, TypeVar, cast import jinja2 import voluptuous as vol @@ -313,7 +313,8 @@ async def async_publish_service(call: ServiceCall) -> None: ) return - assert msg_topic is not None + if TYPE_CHECKING: + assert msg_topic is not None await mqtt_data.client.async_publish(msg_topic, payload, qos, retain) hass.services.async_register( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 166bfdd38ccdc8..edddd0f2239be7 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -4,6 +4,7 @@ from base64 import b64decode import functools import logging +from typing import TYPE_CHECKING import voluptuous as vol @@ -112,7 +113,8 @@ def message_received(msg: ReceiveMessage) -> None: if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload self._sub_state = subscription.async_prepare_subscribe_topics( diff --git a/homeassistant/components/mqtt/config_flow.py b/homeassistant/components/mqtt/config_flow.py index 9f960b0d9099eb..4f46dffec11b2d 100644 --- a/homeassistant/components/mqtt/config_flow.py +++ b/homeassistant/components/mqtt/config_flow.py @@ -6,7 +6,7 @@ import queue from ssl import PROTOCOL_TLS_CLIENT, SSLContext, SSLError from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from cryptography.hazmat.primitives.serialization import load_pem_private_key from cryptography.x509 import load_pem_x509_certificate @@ -224,7 +224,8 @@ async def async_step_hassio_confirm( ) -> FlowResult: """Confirm a Hass.io discovery.""" errors: dict[str, str] = {} - assert self._hassio_discovery + if TYPE_CHECKING: + assert self._hassio_discovery if user_input is not None: data: dict[str, Any] = self._hassio_discovery.copy() @@ -312,7 +313,8 @@ async def async_step_options( def _birth_will(birt_or_will: str) -> dict[str, Any]: """Return the user input for birth or will.""" - assert user_input + if TYPE_CHECKING: + assert user_input return { ATTR_TOPIC: user_input[f"{birt_or_will}_topic"], ATTR_PAYLOAD: user_input.get(f"{birt_or_will}_payload", ""), diff --git a/homeassistant/components/mqtt/debug_info.py b/homeassistant/components/mqtt/debug_info.py index bdbdd74de96797..6b4b90586a7287 100644 --- a/homeassistant/components/mqtt/debug_info.py +++ b/homeassistant/components/mqtt/debug_info.py @@ -5,7 +5,7 @@ from collections.abc import Callable import datetime as dt from functools import wraps -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -128,11 +128,11 @@ def update_entity_discovery_data( hass: HomeAssistant, discovery_payload: DiscoveryInfoType, entity_id: str ) -> None: """Update discovery data.""" - assert ( - discovery_data := get_mqtt_data(hass).debug_info_entities[entity_id][ - "discovery_data" - ] - ) is not None + discovery_data = get_mqtt_data(hass).debug_info_entities[entity_id][ + "discovery_data" + ] + if TYPE_CHECKING: + assert discovery_data is not None discovery_data[ATTR_DISCOVERY_PAYLOAD] = discovery_payload diff --git a/homeassistant/components/mqtt/device_trigger.py b/homeassistant/components/mqtt/device_trigger.py index 36291ae0be836d..fc7528743faea6 100644 --- a/homeassistant/components/mqtt/device_trigger.py +++ b/homeassistant/components/mqtt/device_trigger.py @@ -3,7 +3,7 @@ from collections.abc import Callable import logging -from typing import Any +from typing import TYPE_CHECKING, Any import attr import voluptuous as vol @@ -269,7 +269,8 @@ async def async_setup_trigger( config = TRIGGER_DISCOVERY_SCHEMA(config) device_id = update_device(hass, config_entry, config) - assert isinstance(device_id, str) + if TYPE_CHECKING: + assert isinstance(device_id, str) mqtt_device_trigger = MqttDeviceTrigger( hass, config, device_id, discovery_data, config_entry ) @@ -286,7 +287,8 @@ async def async_removed_from_device(hass: HomeAssistant, device_id: str) -> None if device_trigger: device_trigger.detach_trigger() discovery_data = device_trigger.discovery_data - assert discovery_data is not None + if TYPE_CHECKING: + assert discovery_data is not None discovery_hash = discovery_data[ATTR_DISCOVERY_HASH] debug_info.remove_trigger_discovery_data(hass, discovery_hash) diff --git a/homeassistant/components/mqtt/diagnostics.py b/homeassistant/components/mqtt/diagnostics.py index 173c583ca6a4bc..82bae04d2c9a30 100644 --- a/homeassistant/components/mqtt/diagnostics.py +++ b/homeassistant/components/mqtt/diagnostics.py @@ -1,7 +1,7 @@ """Diagnostics support for MQTT.""" from __future__ import annotations -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components import device_tracker from homeassistant.components.diagnostics import async_redact_data @@ -45,7 +45,8 @@ def _async_get_diagnostics( ) -> dict[str, Any]: """Return diagnostics for a config entry.""" mqtt_instance = get_mqtt_data(hass).client - assert mqtt_instance is not None + if TYPE_CHECKING: + assert mqtt_instance is not None redacted_config = async_redact_data(mqtt_instance.conf, REDACT_CONFIG) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index b05e57280f313e..c78319bb46a58b 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -7,7 +7,7 @@ import logging import re import time -from typing import Any +from typing import TYPE_CHECKING, Any import voluptuous as vol @@ -343,7 +343,8 @@ async def async_integration_message_received( integration: str, msg: ReceiveMessage ) -> None: """Process the received message.""" - assert mqtt_data.data_config_flow_lock + if TYPE_CHECKING: + assert mqtt_data.data_config_flow_lock key = f"{integration}_{msg.subscribed_topic}" # Lock to prevent initiating many parallel config flows. diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index da62416d29e313..da526575a77191 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -6,7 +6,7 @@ from collections.abc import Callable import functools import logging -from typing import Any +from typing import TYPE_CHECKING, Any import httpx import voluptuous as vol @@ -172,7 +172,8 @@ def image_data_received(msg: ReceiveMessage) -> None: if CONF_IMAGE_ENCODING in self._config: self._last_image = b64decode(msg.payload) else: - assert isinstance(msg.payload, bytes) + if TYPE_CHECKING: + assert isinstance(msg.payload, bytes) self._last_image = msg.payload except (binascii.Error, ValueError, AssertionError) as err: _LOGGER.error( diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 3b28bc8804f4a7..ceccfa5adc88ac 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -6,7 +6,7 @@ from collections.abc import Callable, Coroutine from functools import partial import logging -from typing import Any, Protocol, cast, final +from typing import TYPE_CHECKING, Any, Protocol, cast, final import voluptuous as vol @@ -850,7 +850,8 @@ def discovery_callback(payload: MQTTDiscoveryPayload) -> None: discovery_hash, payload, ) - assert self._discovery_data + if TYPE_CHECKING: + assert self._discovery_data old_payload: DiscoveryInfoType old_payload = self._discovery_data[ATTR_DISCOVERY_PAYLOAD] debug_info.update_entity_discovery_data(self.hass, payload, self.entity_id) @@ -877,7 +878,8 @@ def discovery_callback(payload: MQTTDiscoveryPayload) -> None: send_discovery_done(self.hass, self._discovery_data) if discovery_hash: - assert self._discovery_data is not None + if TYPE_CHECKING: + assert self._discovery_data is not None debug_info.add_entity_discovery_data( self.hass, self._discovery_data, self.entity_id ) diff --git a/homeassistant/components/mqtt/subscription.py b/homeassistant/components/mqtt/subscription.py index dda80bba84e20a..3f8f0f4ee3ec3e 100644 --- a/homeassistant/components/mqtt/subscription.py +++ b/homeassistant/components/mqtt/subscription.py @@ -2,7 +2,7 @@ from __future__ import annotations from collections.abc import Callable, Coroutine -from typing import Any +from typing import TYPE_CHECKING, Any import attr @@ -31,7 +31,8 @@ def resubscribe_if_necessary( ) -> None: """Re-subscribe to the new topic if necessary.""" if not self._should_resubscribe(other): - assert other + if TYPE_CHECKING: + assert other self.unsubscribe_callback = other.unsubscribe_callback return From a4cb06d09f28b2e6874c3cd909a8279bab2a1587 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 11:00:50 +0200 Subject: [PATCH 330/640] Also handle DiscovergyClientError as UpdateFailed (#100038) * Also handle DiscovergyClientError as UpdateFailed * Change AccessTokenExpired to InvalidLogin * Also add DiscovergyClientError to config flow and tests --- .../components/discovergy/config_flow.py | 2 +- .../components/discovergy/coordinator.py | 6 +++--- .../components/discovergy/test_config_flow.py | 21 ++++++++++++++++++- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/discovergy/config_flow.py b/homeassistant/components/discovergy/config_flow.py index 3434b1dd84caa5..e035661db100bf 100644 --- a/homeassistant/components/discovergy/config_flow.py +++ b/homeassistant/components/discovergy/config_flow.py @@ -85,7 +85,7 @@ async def _validate_and_save( httpx_client=get_async_client(self.hass), authentication=BasicAuth(), ).meters() - except discovergyError.HTTPError: + except (discovergyError.HTTPError, discovergyError.DiscovergyClientError): errors["base"] = "cannot_connect" except discovergyError.InvalidLogin: errors["base"] = "invalid_auth" diff --git a/homeassistant/components/discovergy/coordinator.py b/homeassistant/components/discovergy/coordinator.py index 1371b1f26ac177..5f27c6a43d2ad1 100644 --- a/homeassistant/components/discovergy/coordinator.py +++ b/homeassistant/components/discovergy/coordinator.py @@ -5,7 +5,7 @@ import logging from pydiscovergy import Discovergy -from pydiscovergy.error import AccessTokenExpired, HTTPError +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from pydiscovergy.models import Meter, Reading from homeassistant.core import HomeAssistant @@ -44,11 +44,11 @@ async def _async_update_data(self) -> Reading: """Get last reading for meter.""" try: return await self.discovergy_client.meter_last_reading(self.meter.meter_id) - except AccessTokenExpired as err: + except InvalidLogin as err: raise ConfigEntryAuthFailed( f"Auth expired while fetching last reading for meter {self.meter.meter_id}" ) from err - except HTTPError as err: + except (HTTPError, DiscovergyClientError) as err: raise UpdateFailed( f"Error while fetching last reading for meter {self.meter.meter_id}" ) from err diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index bc4fd2d9e9df06..ad9fde46b646f2 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -1,7 +1,7 @@ """Test the Discovergy config flow.""" from unittest.mock import Mock, patch -from pydiscovergy.error import HTTPError, InvalidLogin +from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN @@ -114,6 +114,25 @@ async def test_form_cannot_connect(hass: HomeAssistant) -> None: assert result2["errors"] == {"base": "cannot_connect"} +async def test_form_client_error(hass: HomeAssistant) -> None: + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + with patch("pydiscovergy.Discovergy.meters", side_effect=DiscovergyClientError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result2["type"] == data_entry_flow.FlowResultType.FORM + assert result2["errors"] == {"base": "cannot_connect"} + + async def test_form_unknown_exception(hass: HomeAssistant) -> None: """Test we handle cannot connect error.""" result = await hass.config_entries.flow.async_init( From 10bb8f5396b3bfb8192080e67d2bb25f54c858ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tiit=20R=C3=A4tsep?= Date: Mon, 11 Sep 2023 12:15:46 +0300 Subject: [PATCH 331/640] Fix Soma cover tilt (#99717) --- homeassistant/components/soma/cover.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/soma/cover.py b/homeassistant/components/soma/cover.py index 26487756a44a6b..4aa2559b14049f 100644 --- a/homeassistant/components/soma/cover.py +++ b/homeassistant/components/soma/cover.py @@ -51,6 +51,8 @@ class SomaTilt(SomaEntity, CoverEntity): | CoverEntityFeature.STOP_TILT | CoverEntityFeature.SET_TILT_POSITION ) + CLOSED_UP_THRESHOLD = 80 + CLOSED_DOWN_THRESHOLD = 20 @property def current_cover_tilt_position(self) -> int: @@ -60,7 +62,12 @@ def current_cover_tilt_position(self) -> int: @property def is_closed(self) -> bool: """Return if the cover tilt is closed.""" - return self.current_position == 0 + if ( + self.current_position < self.CLOSED_DOWN_THRESHOLD + or self.current_position > self.CLOSED_UP_THRESHOLD + ): + return True + return False def close_cover_tilt(self, **kwargs: Any) -> None: """Close the cover tilt.""" From 58072189fc158554b2ddbeb3bcbcf205786754a7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 11 Sep 2023 12:14:50 +0200 Subject: [PATCH 332/640] Update black to 23.9.1 (#100108) --- .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 50829592f53c31..1a38238e159e55 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.0 + rev: 23.9.1 hooks: - id: black args: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 9663d0a8fb7f4e..98c8f40b82b72c 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.0 +black==23.9.1 codespell==2.2.2 ruff==0.0.285 yamllint==1.32.0 From 5781e5e03e9e86a8de602352de185831cfc40a4d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 12:36:37 +0200 Subject: [PATCH 333/640] Use json to store Withings test data fixtures (#99998) * Decouple Withings sensor tests from yaml * Improve Withings config flow tests * Improve Withings config flow tests * Fix feedback * Use fixtures to store Withings testdata structures * Use fixtures to store Withings testdata structures * Use JSON * Fix * Use load_json_object_fixture --- tests/components/withings/__init__.py | 56 ++-- tests/components/withings/conftest.py | 246 +--------------- .../withings/fixtures/person0_get_device.json | 18 ++ .../withings/fixtures/person0_get_meas.json | 278 ++++++++++++++++++ .../withings/fixtures/person0_get_sleep.json | 60 ++++ .../fixtures/person0_notify_list.json | 3 + tests/components/withings/test_sensor.py | 78 ++--- 7 files changed, 438 insertions(+), 301 deletions(-) create mode 100644 tests/components/withings/fixtures/person0_get_device.json create mode 100644 tests/components/withings/fixtures/person0_get_meas.json create mode 100644 tests/components/withings/fixtures/person0_get_sleep.json create mode 100644 tests/components/withings/fixtures/person0_notify_list.json diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index e148c1a2c847e0..b87188f302224e 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1,6 +1,6 @@ """Tests for the withings component.""" from collections.abc import Iterable -from typing import Any, Optional +from typing import Any from urllib.parse import urlparse import arrow @@ -10,6 +10,8 @@ MeasureGetMeasGroupCategory, MeasureGetMeasResponse, MeasureType, + NotifyAppli, + NotifyListResponse, SleepGetSummaryResponse, UserGetDeviceResponse, ) @@ -17,7 +19,9 @@ from homeassistant.components.webhook import async_generate_url from homeassistant.core import HomeAssistant -from .common import ProfileConfig, WebhookResponse +from .common import WebhookResponse + +from tests.common import load_json_object_fixture async def call_webhook( @@ -43,19 +47,23 @@ async def call_webhook( class MockWithings: """Mock object for Withings.""" - def __init__(self, user_profile: ProfileConfig): + def __init__( + self, + device_fixture: str = "person0_get_device.json", + measurement_fixture: str = "person0_get_meas.json", + sleep_fixture: str = "person0_get_sleep.json", + notify_list_fixture: str = "person0_notify_list.json", + ): """Initialize mock.""" - self.api_response_user_get_device = user_profile.api_response_user_get_device - self.api_response_measure_get_meas = user_profile.api_response_measure_get_meas - self.api_response_sleep_get_summary = ( - user_profile.api_response_sleep_get_summary - ) + self.device_fixture = device_fixture + self.measurement_fixture = measurement_fixture + self.sleep_fixture = sleep_fixture + self.notify_list_fixture = notify_list_fixture def user_get_device(self) -> UserGetDeviceResponse: """Get devices.""" - if isinstance(self.api_response_user_get_device, Exception): - raise self.api_response_user_get_device - return self.api_response_user_get_device + fixture = load_json_object_fixture(f"withings/{self.device_fixture}") + return UserGetDeviceResponse(**fixture) def measure_get_meas( self, @@ -67,19 +75,25 @@ def measure_get_meas( lastupdate: DateType | None = None, ) -> MeasureGetMeasResponse: """Get measurements.""" - if isinstance(self.api_response_measure_get_meas, Exception): - raise self.api_response_measure_get_meas - return self.api_response_measure_get_meas + fixture = load_json_object_fixture(f"withings/{self.measurement_fixture}") + return MeasureGetMeasResponse(**fixture) def sleep_get_summary( self, data_fields: Iterable[GetSleepSummaryField], - startdateymd: Optional[DateType] = arrow.utcnow(), - enddateymd: Optional[DateType] = arrow.utcnow(), - offset: Optional[int] = None, - lastupdate: Optional[DateType] = arrow.utcnow(), + startdateymd: DateType | None = arrow.utcnow(), + enddateymd: DateType | None = arrow.utcnow(), + offset: int | None = None, + lastupdate: DateType | None = arrow.utcnow(), ) -> SleepGetSummaryResponse: """Get sleep.""" - if isinstance(self.api_response_sleep_get_summary, Exception): - raise self.api_response_sleep_get_summary - return self.api_response_sleep_get_summary + fixture = load_json_object_fixture(f"withings/{self.sleep_fixture}") + return SleepGetSummaryResponse(**fixture) + + def notify_list( + self, + appli: NotifyAppli | None = None, + ) -> NotifyListResponse: + """Get sleep.""" + fixture = load_json_object_fixture(f"withings/{self.notify_list_fixture}") + return NotifyListResponse(**fixture) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 510fc980dc72f5..8a85b523769ffd 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -4,20 +4,7 @@ from typing import Any from unittest.mock import patch -import arrow import pytest -from withings_api.common import ( - GetSleepSummaryData, - GetSleepSummarySerie, - MeasureGetMeasGroup, - MeasureGetMeasGroupAttrib, - MeasureGetMeasGroupCategory, - MeasureGetMeasMeasure, - MeasureGetMeasResponse, - MeasureType, - SleepGetSummaryResponse, - SleepModel, -) from homeassistant.components.application_credentials import ( ClientCredential, @@ -27,10 +14,9 @@ from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from homeassistant.util import dt as dt_util from . import MockWithings -from .common import ComponentFactory, new_profile_config +from .common import ComponentFactory from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -46,231 +32,9 @@ "user.sleepevents", ] TITLE = "henk" +USER_ID = 12345 WEBHOOK_ID = "55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" -PERSON0 = new_profile_config( - profile="12345", - user_id=12345, - api_response_measure_get_meas=MeasureGetMeasResponse( - measuregrps=( - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-1), - date=arrow.utcnow().shift(hours=-1), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=70), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=5 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=60 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=50 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=10), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=2), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=20 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=70 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=100 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=60 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=95), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=95 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=100 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow().shift(hours=-2), - date=arrow.utcnow().shift(hours=-2), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=51 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=61 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=21), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=96), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=101 - ), - ), - ), - MeasureGetMeasGroup( - attrib=MeasureGetMeasGroupAttrib.DEVICE_ENTRY_FOR_USER_AMBIGUOUS, - category=MeasureGetMeasGroupCategory.REAL, - created=arrow.utcnow(), - date=arrow.utcnow(), - deviceid="DEV_ID", - grpid=1, - measures=( - MeasureGetMeasMeasure(type=MeasureType.WEIGHT, unit=0, value=71), - MeasureGetMeasMeasure( - type=MeasureType.FAT_MASS_WEIGHT, unit=0, value=4 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_FREE_MASS, unit=0, value=40 - ), - MeasureGetMeasMeasure( - type=MeasureType.MUSCLE_MASS, unit=0, value=51 - ), - MeasureGetMeasMeasure(type=MeasureType.BONE_MASS, unit=0, value=11), - MeasureGetMeasMeasure(type=MeasureType.HEIGHT, unit=0, value=201), - MeasureGetMeasMeasure( - type=MeasureType.TEMPERATURE, unit=0, value=41 - ), - MeasureGetMeasMeasure( - type=MeasureType.BODY_TEMPERATURE, unit=0, value=34 - ), - MeasureGetMeasMeasure( - type=MeasureType.SKIN_TEMPERATURE, unit=0, value=21 - ), - MeasureGetMeasMeasure( - type=MeasureType.FAT_RATIO, unit=-3, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.DIASTOLIC_BLOOD_PRESSURE, unit=0, value=71 - ), - MeasureGetMeasMeasure( - type=MeasureType.SYSTOLIC_BLOOD_PRESSURE, unit=0, value=101 - ), - MeasureGetMeasMeasure( - type=MeasureType.HEART_RATE, unit=0, value=61 - ), - MeasureGetMeasMeasure(type=MeasureType.SP02, unit=-2, value=98), - MeasureGetMeasMeasure( - type=MeasureType.HYDRATION, unit=-2, value=96 - ), - MeasureGetMeasMeasure( - type=MeasureType.PULSE_WAVE_VELOCITY, unit=0, value=102 - ), - ), - ), - ), - more=False, - timezone=dt_util.UTC, - updatetime=arrow.get("2019-08-01"), - offset=0, - ), - api_response_sleep_get_summary=SleepGetSummaryResponse( - more=False, - offset=0, - series=( - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - 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, - ), - ), - GetSleepSummarySerie( - timezone=dt_util.UTC, - model=SleepModel.SLEEP_MONITOR, - startdate=arrow.get("2019-02-01"), - enddate=arrow.get("2019-02-01"), - date=arrow.get("2019-02-01"), - modified=arrow.get(12345), - data=GetSleepSummaryData( - 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, - ), - ), - ), - ), -) - @pytest.fixture def component_factory( @@ -318,12 +82,12 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: return MockConfigEntry( domain=DOMAIN, title=TITLE, - unique_id="12345", + unique_id=str(USER_ID), data={ "auth_implementation": DOMAIN, "token": { "status": 0, - "userid": "12345", + "userid": str(USER_ID), "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_at": expires_at, @@ -356,7 +120,7 @@ async def mock_setup_integration( ) async def func() -> MockWithings: - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, diff --git a/tests/components/withings/fixtures/person0_get_device.json b/tests/components/withings/fixtures/person0_get_device.json new file mode 100644 index 00000000000000..8b5e2686686ea9 --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_device.json @@ -0,0 +1,18 @@ +{ + "status": 0, + "body": { + "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/person0_get_meas.json b/tests/components/withings/fixtures/person0_get_meas.json new file mode 100644 index 00000000000000..a7a2c09156cbea --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_meas.json @@ -0,0 +1,278 @@ +{ + "more": false, + "timezone": "UTC", + "updatetime": 1564617600, + "offset": 0, + "measuregrps": [ + { + "attrib": 0, + "category": 1, + "created": 1564660800, + "date": 1564660800, + "deviceid": "DEV_ID", + "grpid": 1, + "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 + } + ] + }, + { + "attrib": 0, + "category": 1, + "created": 1564657200, + "date": 1564657200, + "deviceid": "DEV_ID", + "grpid": 1, + "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 + } + ] + }, + { + "attrib": 1, + "category": 1, + "created": 1564664400, + "date": 1564664400, + "deviceid": "DEV_ID", + "grpid": 1, + "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 + } + ] + } + ] +} diff --git a/tests/components/withings/fixtures/person0_get_sleep.json b/tests/components/withings/fixtures/person0_get_sleep.json new file mode 100644 index 00000000000000..fdc0e06470941b --- /dev/null +++ b/tests/components/withings/fixtures/person0_get_sleep.json @@ -0,0 +1,60 @@ +{ + "more": false, + "offset": 0, + "series": [ + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "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 + } + }, + { + "timezone": "UTC", + "model": 32, + "startdate": 1548979200, + "enddate": 1548979200, + "date": 1548979200, + "modified": 12345, + "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 + } + } + ] +} diff --git a/tests/components/withings/fixtures/person0_notify_list.json b/tests/components/withings/fixtures/person0_notify_list.json new file mode 100644 index 00000000000000..c905c95e4cbcc7 --- /dev/null +++ b/tests/components/withings/fixtures/person0_notify_list.json @@ -0,0 +1,3 @@ +{ + "profiles": [] +} diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 07fcb8fedaa4af..6ab0fc97f4e91a 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -16,7 +16,7 @@ from . import MockWithings, call_webhook from .common import async_get_entity_id -from .conftest import PERSON0, WEBHOOK_ID, ComponentSetup +from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup from tests.typing import ClientSessionGenerator @@ -26,36 +26,36 @@ EXPECTED_DATA = ( - (PERSON0, Measurement.WEIGHT_KG, 70.0), - (PERSON0, Measurement.FAT_MASS_KG, 5.0), - (PERSON0, Measurement.FAT_FREE_MASS_KG, 60.0), - (PERSON0, Measurement.MUSCLE_MASS_KG, 50.0), - (PERSON0, Measurement.BONE_MASS_KG, 10.0), - (PERSON0, Measurement.HEIGHT_M, 2.0), - (PERSON0, Measurement.FAT_RATIO_PCT, 0.07), - (PERSON0, Measurement.DIASTOLIC_MMHG, 70.0), - (PERSON0, Measurement.SYSTOLIC_MMGH, 100.0), - (PERSON0, Measurement.HEART_PULSE_BPM, 60.0), - (PERSON0, Measurement.SPO2_PCT, 0.95), - (PERSON0, Measurement.HYDRATION, 0.95), - (PERSON0, Measurement.PWV, 100.0), - (PERSON0, Measurement.SLEEP_BREATHING_DISTURBANCES_INTENSITY, 160.0), - (PERSON0, Measurement.SLEEP_DEEP_DURATION_SECONDS, 322), - (PERSON0, Measurement.SLEEP_HEART_RATE_AVERAGE, 164.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MAX, 165.0), - (PERSON0, Measurement.SLEEP_HEART_RATE_MIN, 166.0), - (PERSON0, Measurement.SLEEP_LIGHT_DURATION_SECONDS, 334), - (PERSON0, Measurement.SLEEP_REM_DURATION_SECONDS, 336), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_AVERAGE, 169.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MAX, 170.0), - (PERSON0, Measurement.SLEEP_RESPIRATORY_RATE_MIN, 171.0), - (PERSON0, Measurement.SLEEP_SCORE, 222), - (PERSON0, Measurement.SLEEP_SNORING, 173.0), - (PERSON0, Measurement.SLEEP_SNORING_EPISODE_COUNT, 348), - (PERSON0, Measurement.SLEEP_TOSLEEP_DURATION_SECONDS, 162.0), - (PERSON0, Measurement.SLEEP_TOWAKEUP_DURATION_SECONDS, 163.0), - (PERSON0, Measurement.SLEEP_WAKEUP_COUNT, 350), - (PERSON0, Measurement.SLEEP_WAKEUP_DURATION_SECONDS, 176.0), + (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), ) @@ -84,7 +84,7 @@ async def test_sensor_default_enabled_entities( await setup_integration() entity_registry: EntityRegistry = er.async_get(hass) - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, @@ -93,31 +93,31 @@ async def test_sensor_default_enabled_entities( # Assert entities should exist. for attribute in SENSORS: entity_id = await async_get_entity_id( - hass, attribute, PERSON0.user_id, SENSOR_DOMAIN + 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": PERSON0.user_id, "appli": NotifyAppli.SLEEP}, + {"userid": USER_ID, "appli": NotifyAppli.SLEEP}, client, ) assert resp.message_code == 0 resp = await call_webhook( hass, WEBHOOK_ID, - {"userid": PERSON0.user_id, "appli": NotifyAppli.WEIGHT}, + {"userid": USER_ID, "appli": NotifyAppli.WEIGHT}, client, ) assert resp.message_code == 0 assert resp.message_code == 0 - for person, measurement, expected in EXPECTED_DATA: + for measurement, expected in EXPECTED_DATA: attribute = WITHINGS_MEASUREMENTS_MAP[measurement] entity_id = await async_get_entity_id( - hass, attribute, person.user_id, SENSOR_DOMAIN + hass, attribute, USER_ID, SENSOR_DOMAIN ) state_obj = hass.states.get(entity_id) @@ -131,11 +131,11 @@ async def test_all_entities( """Test all entities.""" await setup_integration() - mock = MockWithings(PERSON0) + mock = MockWithings() with patch( "homeassistant.components.withings.common.ConfigEntryWithingsApi", return_value=mock, ): for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, 12345, SENSOR_DOMAIN) + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) assert hass.states.get(entity_id) == snapshot From a6e9bf830c63f63dc6cc7f08e09a1e975e3f604c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 13:58:47 +0200 Subject: [PATCH 334/640] Decouple Withings binary sensor test from YAML (#100120) --- .../components/withings/test_binary_sensor.py | 101 +++++++----------- 1 file changed, 38 insertions(+), 63 deletions(-) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 03d72c452960ce..e9eebbe356770a 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,76 +1,51 @@ """Tests for the Withings component.""" +from unittest.mock import patch + from withings_api.common import NotifyAppli -from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN -from homeassistant.components.withings.binary_sensor import BINARY_SENSORS -from homeassistant.components.withings.common import WithingsEntityDescription -from homeassistant.components.withings.const import Measurement from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.entity_registry import EntityRegistry -from .common import ComponentFactory, async_get_entity_id, new_profile_config +from . import MockWithings, call_webhook +from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup -WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { - attr.measurement: attr for attr in BINARY_SENSORS -} +from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, - component_factory: ComponentFactory, - current_request_with_host: None, + setup_integration: ComponentSetup, + hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - in_bed_attribute = WITHINGS_MEASUREMENTS_MAP[Measurement.IN_BED] - person0 = new_profile_config("person0", 0) - person1 = new_profile_config("person1", 1) - - entity_registry: EntityRegistry = er.async_get(hass) - - await component_factory.configure_component(profile_configs=(person0, person1)) - assert not await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - assert not await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) - - # person 0 - await component_factory.setup_profile(person0.user_id) - await component_factory.setup_profile(person1.user_id) - - entity_id0 = await async_get_entity_id( - hass, in_bed_attribute, person0.user_id, BINARY_SENSOR_DOMAIN - ) - entity_id1 = await async_get_entity_id( - hass, in_bed_attribute, person1.user_id, BINARY_SENSOR_DOMAIN - ) - assert entity_id0 - assert entity_id1 - - assert entity_registry.async_is_registered(entity_id0) - assert hass.states.get(entity_id0).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_IN) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_ON - - resp = await component_factory.call_webhook(person0.user_id, NotifyAppli.BED_OUT) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id0).state == STATE_OFF - - # person 1 - assert hass.states.get(entity_id1).state == STATE_UNAVAILABLE - - resp = await component_factory.call_webhook(person1.user_id, NotifyAppli.BED_IN) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id1).state == STATE_ON - - # Unload - await component_factory.unload(person0) - await component_factory.unload(person1) + await setup_integration() + mock = MockWithings() + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF From 42046a3ce2634df3841f470df2be6d19415e150e Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 14:33:43 +0200 Subject: [PATCH 335/640] Fix TriggerEntity.async_added_to_hass (#100119) --- .../components/template/trigger_entity.py | 3 +- tests/components/template/test_sensor.py | 44 ++++++++++++++++++- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/template/trigger_entity.py b/homeassistant/components/template/trigger_entity.py index ca2f7240086d78..5f5fbe5b99a16e 100644 --- a/homeassistant/components/template/trigger_entity.py +++ b/homeassistant/components/template/trigger_entity.py @@ -23,8 +23,7 @@ def __init__( async def async_added_to_hass(self) -> None: """Handle being added to Home Assistant.""" - await TriggerBaseEntity.async_added_to_hass(self) - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() if self.coordinator.data is not None: self._process_data() diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index cf9f3724020bfe..0ca666d22f1e60 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1,7 +1,7 @@ """The test for the Template sensor platform.""" from asyncio import Event from datetime import timedelta -from unittest.mock import patch +from unittest.mock import ANY, patch import pytest from syrupy.assertion import SnapshotAssertion @@ -1192,6 +1192,48 @@ async def test_trigger_entity( assert state.context is context +@pytest.mark.parametrize(("count", "domain"), [(1, "template")]) +@pytest.mark.parametrize( + "config", + [ + { + "template": [ + { + "trigger": {"platform": "event", "event_type": "test_event"}, + "sensors": { + "hello": { + "friendly_name": "Hello Name", + "value_template": "{{ trigger.event.data.beer }}", + "entity_picture_template": "{{ '/local/dogs.png' }}", + "icon_template": "{{ 'mdi:pirate' }}", + "attribute_templates": { + "last": "{{now().strftime('%D %X')}}", + "history_1": "{{this.attributes.last|default('Not yet set')}}", + }, + }, + }, + }, + ], + }, + ], +) +async def test_trigger_entity_runs_once( + hass: HomeAssistant, start_ha, entity_registry: er.EntityRegistry +) -> None: + """Test trigger entity handles a trigger once.""" + state = hass.states.get("sensor.hello_name") + assert state is not None + assert state.state == STATE_UNKNOWN + + hass.bus.async_fire("test_event", {"beer": 2}) + await hass.async_block_till_done() + + state = hass.states.get("sensor.hello_name") + assert state.state == "2" + assert state.attributes.get("last") == ANY + assert state.attributes.get("history_1") == "Not yet set" + + @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) @pytest.mark.parametrize( "config", From a6f325d05a5fbef14e08ff4b16b269dd81471857 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 11 Sep 2023 14:36:01 +0200 Subject: [PATCH 336/640] Cache device trigger info during ZHA startup (#99764) * Do not connect to the radio hardware within `_connect_zigpy_app` * Make `connect_zigpy_app` public * Create radio manager instances from config entries * Cache device triggers on startup * reorg zha init * don't reuse gateway * don't nuke yaml configuration * review comments * Fix existing unit tests * Ensure `app.shutdown` is called, not just `app.disconnect` * Revert creating group entities and device registry entries early * Add unit tests --------- Co-authored-by: David F. Mulcahey --- homeassistant/components/zha/__init__.py | 47 ++++++- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 21 +-- homeassistant/components/zha/core/gateway.py | 48 +++---- .../components/zha/device_trigger.py | 93 +++++++------ homeassistant/components/zha/radio_manager.py | 33 +++-- .../homeassistant_hardware/conftest.py | 2 +- .../homeassistant_sky_connect/conftest.py | 2 +- .../homeassistant_sky_connect/test_init.py | 2 +- .../homeassistant_yellow/conftest.py | 2 +- tests/components/zha/conftest.py | 25 +++- tests/components/zha/test_config_flow.py | 7 - tests/components/zha/test_device_trigger.py | 130 ++++++++++++++++-- tests/components/zha/test_init.py | 16 +-- tests/components/zha/test_radio_manager.py | 6 +- 15 files changed, 300 insertions(+), 135 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index f9113ebaa907a9..662ddd080e0367 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -1,5 +1,6 @@ """Support for Zigbee Home Automation devices.""" import asyncio +import contextlib import copy import logging import os @@ -33,13 +34,16 @@ CONF_ZIGPY, DATA_ZHA, DATA_ZHA_CONFIG, + DATA_ZHA_DEVICE_TRIGGER_CACHE, DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, RadioType, ) +from .core.device import get_device_automation_triggers from .core.discovery import GROUP_PROBE +from .radio_manager import ZhaRadioManager DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) ZHA_CONFIG_SCHEMA = { @@ -134,9 +138,43 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b else: _LOGGER.debug("ZHA storage file does not exist or was already removed") - # Re-use the gateway object between ZHA reloads - if (zha_gateway := zha_data.get(DATA_ZHA_GATEWAY)) is None: - zha_gateway = ZHAGateway(hass, config, config_entry) + # Load and cache device trigger information early + zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {}) + + device_registry = dr.async_get(hass) + radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) + + async with radio_mgr.connect_zigpy_app() as app: + for dev in app.devices.values(): + dev_entry = device_registry.async_get_device( + identifiers={(DOMAIN, str(dev.ieee))}, + connections={(dr.CONNECTION_ZIGBEE, str(dev.ieee))}, + ) + + if dev_entry is None: + continue + + zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = ( + str(dev.ieee), + get_device_automation_triggers(dev), + ) + + _LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE]) + + zha_gateway = ZHAGateway(hass, 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 hass.data[DATA_ZHA][platform] + + config_entry.async_on_unload(async_zha_shutdown) try: await zha_gateway.async_initialize() @@ -155,9 +193,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b repairs.async_delete_blocking_issues(hass) - config_entry.async_on_unload(zha_gateway.shutdown) - - device_registry = dr.async_get(hass) device_registry.async_get_or_create( config_entry_id=config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_gateway.coordinator_ieee))}, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 63b59e9d8d42f8..9569fc4965968c 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -186,6 +186,7 @@ DATA_ZHA_CONFIG = "config" DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" +DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" DATA_ZHA_GATEWAY = "zha_gateway" DEBUG_COMP_BELLOWS = "bellows" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 1455173b27c217..60bf78e516c227 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -93,6 +93,16 @@ _CHECKIN_GRACE_PERIODS = 2 +def get_device_automation_triggers( + device: zigpy.device.Device, +) -> dict[tuple[str, str], dict[str, str]]: + """Get the supported device automation triggers for a zigpy device.""" + return { + ("device_offline", "device_offline"): {"device_event_type": "device_offline"}, + **getattr(device, "device_automation_triggers", {}), + } + + class DeviceStatus(Enum): """Status of a device.""" @@ -311,16 +321,7 @@ def device_automation_commands(self) -> dict[str, list[tuple[str, str]]]: @cached_property def device_automation_triggers(self) -> dict[tuple[str, str], dict[str, str]]: """Return the device automation triggers for this device.""" - triggers = { - ("device_offline", "device_offline"): { - "device_event_type": "device_offline" - } - } - - if hasattr(self._zigpy_device, "device_automation_triggers"): - triggers.update(self._zigpy_device.device_automation_triggers) - - return triggers + return get_device_automation_triggers(self._zigpy_device) @property def available_signal(self) -> str: diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 353bc6904d7954..5cc2cd9a4b9216 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -149,12 +149,6 @@ def __init__( self.config_entry = config_entry self._unsubs: list[Callable[[], None]] = [] - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) - def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: """Get an uninitialized instance of a zigpy `ControllerApplication`.""" radio_type = self.config_entry.data[CONF_RADIO_TYPE] @@ -197,6 +191,12 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" + discovery.PROBE.initialize(self._hass) + discovery.GROUP_PROBE.initialize(self._hass) + + self.ha_device_registry = dr.async_get(self._hass) + self.ha_entity_registry = er.async_get(self._hass) + app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( config=app_config, @@ -204,23 +204,6 @@ async def async_initialize(self) -> None: start_radio=False, ) - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - - self.async_load_devices() - - # Groups are attached to the coordinator device so we need to load it early - coordinator = self._find_coordinator_device() - loaded_groups = False - - # We can only load groups early if the coordinator's model info has been stored - # in the zigpy database - if coordinator.model is not None: - self.coordinator_zha_device = self._async_get_or_create_device( - coordinator, restored=True - ) - self.async_load_groups() - loaded_groups = True - for attempt in range(STARTUP_RETRIES): try: await self.application_controller.startup(auto_form=True) @@ -242,14 +225,15 @@ async def async_initialize(self) -> None: else: break + self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self + self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + self.coordinator_zha_device = self._async_get_or_create_device( self._find_coordinator_device(), restored=True ) - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) - # If ZHA groups could not load early, we can safely load them now - if not loaded_groups: - self.async_load_groups() + self.async_load_devices() + self.async_load_groups() self.application_controller.add_listener(self) self.application_controller.groups.add_listener(self) @@ -766,7 +750,15 @@ async def shutdown(self) -> None: unsubscribe() for device in self.devices.values(): device.async_cleanup_handles() - await self.application_controller.shutdown() + # shutdown is called when the config entry unloads are processed + # there are cases where unloads are processed because of a failure of + # some sort and the application controller may not have been + # created yet + if ( + hasattr(self, "application_controller") + and self.application_controller is not None + ): + await self.application_controller.shutdown() def handle_message( self, diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 9e33e3fa6159a7..7a479443377370 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -9,12 +9,12 @@ from homeassistant.components.homeassistant.triggers import event as event_trigger from homeassistant.const import CONF_DEVICE_ID, CONF_DOMAIN, CONF_PLATFORM, CONF_TYPE from homeassistant.core import CALLBACK_TYPE, HomeAssistant -from homeassistant.exceptions import HomeAssistantError, IntegrationError +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.trigger import TriggerActionType, TriggerInfo from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN -from .core.const import ZHA_EVENT +from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT from .core.helpers import async_get_zha_device CONF_SUBTYPE = "subtype" @@ -26,21 +26,32 @@ ) +def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, dict]: + """Get device trigger data for a device, falling back to the cache if possible.""" + + # First, try checking to see if the device itself is accessible + try: + zha_device = async_get_zha_device(hass, device_id) + except KeyError: + pass + else: + return str(zha_device.ieee), zha_device.device_automation_triggers + + # If not, check the trigger cache but allow any `KeyError`s to propagate + return hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id] + + async def async_validate_trigger_config( hass: HomeAssistant, config: ConfigType ) -> ConfigType: """Validate config.""" config = TRIGGER_SCHEMA(config) + # Trigger validation will not occur if the config entry is not loaded + _, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + trigger = (config[CONF_TYPE], config[CONF_SUBTYPE]) - try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError, IntegrationError) as err: - raise InvalidDeviceAutomationConfig from err - if ( - zha_device.device_automation_triggers is None - or trigger not in zha_device.device_automation_triggers - ): + if trigger not in triggers: raise InvalidDeviceAutomationConfig(f"device does not have trigger {trigger}") return config @@ -53,26 +64,26 @@ async def async_attach_trigger( trigger_info: TriggerInfo, ) -> CALLBACK_TYPE: """Listen for state changes based on configuration.""" - trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) + try: - zha_device = async_get_zha_device(hass, config[CONF_DEVICE_ID]) - except (KeyError, AttributeError) as err: + ieee, triggers = _get_device_trigger_data(hass, config[CONF_DEVICE_ID]) + except KeyError as err: raise HomeAssistantError( f"Unable to get zha device {config[CONF_DEVICE_ID]}" ) from err - if trigger_key not in zha_device.device_automation_triggers: - raise HomeAssistantError(f"Unable to find trigger {trigger_key}") - - trigger = zha_device.device_automation_triggers[trigger_key] + trigger_key: tuple[str, str] = (config[CONF_TYPE], config[CONF_SUBTYPE]) - event_config = { - event_trigger.CONF_PLATFORM: "event", - event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, - event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: str(zha_device.ieee), **trigger}, - } + if trigger_key not in triggers: + raise HomeAssistantError(f"Unable to find trigger {trigger_key}") - event_config = event_trigger.TRIGGER_SCHEMA(event_config) + event_config = event_trigger.TRIGGER_SCHEMA( + { + event_trigger.CONF_PLATFORM: "event", + event_trigger.CONF_EVENT_TYPE: ZHA_EVENT, + event_trigger.CONF_EVENT_DATA: {DEVICE_IEEE: ieee, **triggers[trigger_key]}, + } + ) return await event_trigger.async_attach_trigger( hass, event_config, action, trigger_info, platform_type="device" ) @@ -83,24 +94,20 @@ async def async_get_triggers( ) -> list[dict[str, str]]: """List device triggers. - Make sure the device supports device automations and - if it does return the trigger list. + Make sure the device supports device automations and return the trigger list. """ - zha_device = async_get_zha_device(hass, device_id) - - if not zha_device.device_automation_triggers: - return [] - - triggers = [] - for trigger, subtype in zha_device.device_automation_triggers: - triggers.append( - { - CONF_DEVICE_ID: device_id, - CONF_DOMAIN: ZHA_DOMAIN, - CONF_PLATFORM: DEVICE, - CONF_TYPE: trigger, - CONF_SUBTYPE: subtype, - } - ) - - return triggers + try: + _, triggers = _get_device_trigger_data(hass, device_id) + except KeyError as err: + raise InvalidDeviceAutomationConfig from err + + return [ + { + CONF_DEVICE_ID: device_id, + CONF_DOMAIN: ZHA_DOMAIN, + CONF_PLATFORM: DEVICE, + CONF_TYPE: trigger, + CONF_SUBTYPE: subtype, + } + for trigger, subtype in triggers + ] diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index 751fea99847d99..df30a85cd7bb82 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -8,7 +8,7 @@ import enum import logging import os -from typing import Any +from typing import Any, Self from bellows.config import CONF_USE_THREAD import voluptuous as vol @@ -127,8 +127,21 @@ def __init__(self) -> None: self.backups: list[zigpy.backups.NetworkBackup] = [] self.chosen_backup: zigpy.backups.NetworkBackup | None = None + @classmethod + def from_config_entry( + cls, hass: HomeAssistant, config_entry: config_entries.ConfigEntry + ) -> Self: + """Create an instance from a config entry.""" + mgr = cls() + mgr.hass = hass + mgr.device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH] + mgr.device_settings = config_entry.data[CONF_DEVICE] + mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]] + + return mgr + @contextlib.asynccontextmanager - async def _connect_zigpy_app(self) -> ControllerApplication: + async def connect_zigpy_app(self) -> ControllerApplication: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None @@ -155,10 +168,9 @@ async def _connect_zigpy_app(self) -> ControllerApplication: ) try: - await app.connect() yield app finally: - await app.disconnect() + await app.shutdown() await asyncio.sleep(CONNECT_DELAY_S) async def restore_backup( @@ -170,7 +182,8 @@ async def restore_backup( ): return - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.backups.restore_backup(backup, **kwargs) @staticmethod @@ -218,7 +231,9 @@ async def async_load_network_settings( """Connect to the radio and load its current network settings.""" backup = None - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() + # Check if the stick has any settings and load them try: await app.load_network_info() @@ -241,12 +256,14 @@ async def async_load_network_settings( async def async_form_network(self) -> None: """Form a brand-new network.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.form_network() async def async_reset_adapter(self) -> None: """Reset the current adapter.""" - async with self._connect_zigpy_app() as app: + async with self.connect_zigpy_app() as app: + await app.connect() await app.reset_network_info() async def async_restore_backup_step_1(self) -> bool: diff --git a/tests/components/homeassistant_hardware/conftest.py b/tests/components/homeassistant_hardware/conftest.py index 60083c2de94421..02b468e558e348 100644 --- a/tests/components/homeassistant_hardware/conftest.py +++ b/tests/components/homeassistant_hardware/conftest.py @@ -23,7 +23,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/conftest.py b/tests/components/homeassistant_sky_connect/conftest.py index 85017866db9c55..90dbe5af384769 100644 --- a/tests/components/homeassistant_sky_connect/conftest.py +++ b/tests/components/homeassistant_sky_connect/conftest.py @@ -25,7 +25,7 @@ def mock_zha(): ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/homeassistant_sky_connect/test_init.py b/tests/components/homeassistant_sky_connect/test_init.py index cbf1cfa7d36b55..3afc8c24774fd4 100644 --- a/tests/components/homeassistant_sky_connect/test_init.py +++ b/tests/components/homeassistant_sky_connect/test_init.py @@ -45,7 +45,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield diff --git a/tests/components/homeassistant_yellow/conftest.py b/tests/components/homeassistant_yellow/conftest.py index e4a666f9f04fc3..a7d66d659f01b9 100644 --- a/tests/components/homeassistant_yellow/conftest.py +++ b/tests/components/homeassistant_yellow/conftest.py @@ -23,7 +23,7 @@ def mock_probe(config: dict[str, Any]) -> None: with patch( "bellows.zigbee.application.ControllerApplication.probe", side_effect=mock_probe ), patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ), patch( "homeassistant.components.zha.async_setup_entry", diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 4778f3216da16b..7d391872a77d4c 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -293,14 +293,20 @@ def _mock_dev( return _mock_dev +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_joined(hass, setup_zha): """Return a newly joined ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev): + async def _zha_device(zigpy_dev, *, setup_zha: bool = True): zigpy_dev.last_seen = time.time() - await setup_zha() + + if setup_zha: + await setup_zha_fixture() + zha_gateway = common.get_zha_gateway(hass) + zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() return zha_gateway.get_device(zigpy_dev.ieee) @@ -308,17 +314,21 @@ async def _zha_device(zigpy_dev): return _zha_device +@patch("homeassistant.components.zha.setup_quirks", MagicMock(return_value=True)) @pytest.fixture def zha_device_restored(hass, zigpy_app_controller, setup_zha): """Return a restored ZHA device.""" + setup_zha_fixture = setup_zha - async def _zha_device(zigpy_dev, last_seen=None): + async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True): zigpy_app_controller.devices[zigpy_dev.ieee] = zigpy_dev if last_seen is not None: zigpy_dev.last_seen = last_seen - await setup_zha() + if setup_zha: + await setup_zha_fixture() + zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] return zha_gateway.get_device(zigpy_dev.ieee) @@ -376,3 +386,10 @@ def hass_disable_services(hass): hass, "services", MagicMock(has_service=MagicMock(return_value=True)) ): yield hass + + +@pytest.fixture(autouse=True) +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 diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index d97a0de0d585e8..981ca2aca380fa 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -63,13 +63,6 @@ def mock_multipan_platform(): yield -@pytest.fixture(autouse=True) -def reduce_reconnect_timeout(): - """Reduces reconnect timeout to speed up tests.""" - with patch("homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.01): - yield - - @pytest.fixture(autouse=True) def mock_app(): """Mock zigpy app interface.""" diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 22f62cb977a871..491e2d96d4f70b 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -9,6 +9,9 @@ import homeassistant.components.automation as automation from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.device_automation.exceptions import ( + InvalidDeviceAutomationConfig, +) from homeassistant.components.zha.core.const import ATTR_ENDPOINT_ID from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -20,6 +23,7 @@ from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from tests.common import ( + MockConfigEntry, async_fire_time_changed, async_get_device_automations, async_mock_service, @@ -45,6 +49,16 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: LONG_RELEASE = "remote_button_long_release" +SWITCH_SIGNATURE = { + 1: { + SIG_EP_INPUT: [general.Basic.cluster_id], + SIG_EP_OUTPUT: [general.OnOff.cluster_id], + SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, + SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, + } +} + + @pytest.fixture(autouse=True) def sensor_platforms_only(): """Only set up the sensor platform and required base platforms to speed up tests.""" @@ -72,16 +86,7 @@ def calls(hass): async def mock_devices(hass, zigpy_device_mock, zha_device_joined_restored): """IAS device fixture.""" - zigpy_device = zigpy_device_mock( - { - 1: { - SIG_EP_INPUT: [general.Basic.cluster_id], - SIG_EP_OUTPUT: [general.OnOff.cluster_id], - SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, - SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, - } - } - ) + zigpy_device = zigpy_device_mock(SWITCH_SIGNATURE) zha_device = await zha_device_joined_restored(zigpy_device) zha_device.update_available(True) @@ -397,3 +402,108 @@ async def test_exception_bad_trigger( "Unnamed automation failed to setup triggers and has been disabled: " "device does not have trigger ('junk', 'junk')" in caplog.text ) + + +async def test_validate_trigger_config_missing_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to get zha device" in caplog.text + + with pytest.raises(InvalidDeviceAutomationConfig): + await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, reg_device.id + ) + + +async def test_validate_trigger_config_unloaded_bad_info( + hass: HomeAssistant, + config_entry: MockConfigEntry, + zigpy_device_mock, + mock_zigpy_connect, + zha_device_joined, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test device triggers referring to a missing device.""" + + # Join a device + switch = zigpy_device_mock(SWITCH_SIGNATURE) + await zha_device_joined(switch) + + # After we unload the config entry, trigger info was not cached on startup, nor can + # it be pulled from the current device, making it impossible to validate triggers + await hass.config_entries.async_unload(config_entry.entry_id) + + # Reload ZHA to persist the device info in the cache + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.config_entries.async_unload(config_entry.entry_id) + + ha_device_registry = dr.async_get(hass) + reg_device = ha_device_registry.async_get_device( + identifiers={("zha", str(switch.ieee))} + ) + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "device_id": reg_device.id, + "domain": "zha", + "platform": "device", + "type": "junk", + "subtype": "junk", + }, + "action": { + "service": "test.automation", + "data": {"message": "service called"}, + }, + } + ] + }, + ) + + assert "Unable to find trigger" in caplog.text diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 63ca10bbf91961..6bac012d667eb9 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -6,7 +6,6 @@ from zigpy.config import CONF_DEVICE, CONF_DEVICE_PATH from zigpy.exceptions import TransientConnectionError -from homeassistant.components.zha import async_setup_entry from homeassistant.components.zha.core.const import ( CONF_BAUDRATE, CONF_RADIO_TYPE, @@ -22,7 +21,7 @@ from tests.common import MockConfigEntry -DATA_RADIO_TYPE = "deconz" +DATA_RADIO_TYPE = "ezsp" DATA_PORT_PATH = "/dev/serial/by-id/FTDI_USB__-__Serial_Cable_12345678-if00-port0" @@ -137,7 +136,7 @@ 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 + hass: HomeAssistant, path: str, cleaned_path: str, mock_zigpy_connect ) -> None: """Test migration of config entry from v3, applying corrections to the port path.""" config_entry_v3 = MockConfigEntry( @@ -150,14 +149,9 @@ async def test_setup_with_v3_cleaning_uri( ) config_entry_v3.add_to_hass(hass) - with patch( - "homeassistant.components.zha.ZHAGateway", return_value=AsyncMock() - ) as mock_gateway: - mock_gateway.return_value.coordinator_ieee = "mock_ieee" - mock_gateway.return_value.radio_description = "mock_radio" - - assert await async_setup_entry(hass, config_entry_v3) - hass.data[DOMAIN]["zha_gateway"] = mock_gateway.return_value + await hass.config_entries.async_setup(config_entry_v3.entry_id) + await hass.async_block_till_done() + await hass.config_entries.async_unload(config_entry_v3.entry_id) assert config_entry_v3.data[CONF_RADIO_TYPE] == DATA_RADIO_TYPE assert config_entry_v3.data[CONF_DEVICE][CONF_DEVICE_PATH] == cleaned_path diff --git a/tests/components/zha/test_radio_manager.py b/tests/components/zha/test_radio_manager.py index 7acf9219d67498..1467e2e2951de6 100644 --- a/tests/components/zha/test_radio_manager.py +++ b/tests/components/zha/test_radio_manager.py @@ -32,9 +32,7 @@ def disable_platform_only(): @pytest.fixture(autouse=True) def reduce_reconnect_timeout(): """Reduces reconnect timeout to speed up tests.""" - with patch( - "homeassistant.components.zha.radio_manager.CONNECT_DELAY_S", 0.0001 - ), patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): + with patch("homeassistant.components.zha.radio_manager.RETRY_DELAY_S", 0.0001): yield @@ -99,7 +97,7 @@ def mock_connect_zigpy_app() -> Generator[MagicMock, None, None]: ) with patch( - "homeassistant.components.zha.radio_manager.ZhaRadioManager._connect_zigpy_app", + "homeassistant.components.zha.radio_manager.ZhaRadioManager.connect_zigpy_app", return_value=mock_connect_app, ): yield mock_connect_app From 64fde640cab1c69ab7f365a576f79ec8baa89abc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:08:19 -0500 Subject: [PATCH 337/640] Bump pyunifiprotect to 4.20.0 (#100092) --- homeassistant/components/unifiprotect/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/unifiprotect/manifest.json b/homeassistant/components/unifiprotect/manifest.json index 5f2f58ce98aa88..b63700720e61e1 100644 --- a/homeassistant/components/unifiprotect/manifest.json +++ b/homeassistant/components/unifiprotect/manifest.json @@ -41,7 +41,7 @@ "iot_class": "local_push", "loggers": ["pyunifiprotect", "unifi_discovery"], "quality_scale": "platinum", - "requirements": ["pyunifiprotect==4.10.6", "unifi-discovery==1.1.7"], + "requirements": ["pyunifiprotect==4.20.0", "unifi-discovery==1.1.7"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index 77d67f856752be..25f365c7825c23 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2205,7 +2205,7 @@ pytrafikverket==0.3.6 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 054a38314a4854..658fefa8144651 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1628,7 +1628,7 @@ pytrafikverket==0.3.6 pyudev==0.23.2 # homeassistant.components.unifiprotect -pyunifiprotect==4.10.6 +pyunifiprotect==4.20.0 # homeassistant.components.uptimerobot pyuptimerobot==22.2.0 From f4a7bb47fe4e4e2c883a1d9d5759dd4dcb39f7ca Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:09:29 -0500 Subject: [PATCH 338/640] Bump zeroconf to 0.105.0 (#100084) --- 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 7d6cc32c8f1743..0457f7fd1c327e 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.104.0"] + "requirements": ["zeroconf==0.105.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 74aca53df9c881..4d2d45de477e91 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.104.0 +zeroconf==0.105.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 25f365c7825c23..c588d7b952371d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2766,7 +2766,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.104.0 +zeroconf==0.105.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 658fefa8144651..341901b845a560 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2042,7 +2042,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.104.0 +zeroconf==0.105.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 791482406c701e7cdfbcdd64bf1a7cac1b2e145b Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 08:13:25 -0500 Subject: [PATCH 339/640] Cleanup isinstance checks in zeroconf (#100090) --- homeassistant/components/zeroconf/__init__.py | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index b85f9f0fd83657..085e720e3df9bc 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -11,7 +11,7 @@ import logging import re import sys -from typing import Any, Final, cast +from typing import TYPE_CHECKING, Any, Final, cast import voluptuous as vol from zeroconf import ( @@ -303,7 +303,8 @@ def _match_against_data( if key not in match_data: return False match_val = matcher[key] - assert isinstance(match_val, str) + if TYPE_CHECKING: + assert isinstance(match_val, str) if not _memorized_fnmatch(match_data[key], match_val): return False @@ -485,12 +486,14 @@ def _async_process_service_update( continue if ATTR_PROPERTIES in matcher: matcher_props = matcher[ATTR_PROPERTIES] - assert isinstance(matcher_props, dict) + if TYPE_CHECKING: + assert isinstance(matcher_props, dict) if not _match_against_props(matcher_props, props): continue matcher_domain = matcher["domain"] - assert isinstance(matcher_domain, str) + if TYPE_CHECKING: + assert isinstance(matcher_domain, str) context = { "source": config_entries.SOURCE_ZEROCONF, } @@ -516,11 +519,11 @@ def async_get_homekit_discovery( Return the domain to forward the discovery data to """ - if not (model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER)): + if not ( + model := props.get(HOMEKIT_MODEL_LOWER) or props.get(HOMEKIT_MODEL_UPPER) + ) or not isinstance(model, str): return None - assert isinstance(model, str) - for split_str in _HOMEKIT_MODEL_SPLITS: key = (model.split(split_str))[0] if split_str else model if discovery := homekit_model_lookups.get(key): From d8445a79fc2d0231571b85419f7d26f23eb41b37 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 11 Sep 2023 15:55:27 +0200 Subject: [PATCH 340/640] UniFi streamline loading platforms (#100071) * Streamline loading platforms * Move platform registration logic to UnifiController class --- homeassistant/components/unifi/button.py | 15 +++--- homeassistant/components/unifi/controller.py | 54 ++++++++----------- .../components/unifi/device_tracker.py | 6 +-- homeassistant/components/unifi/image.py | 15 +++--- homeassistant/components/unifi/sensor.py | 6 +-- homeassistant/components/unifi/switch.py | 22 +++----- homeassistant/components/unifi/update.py | 16 +++--- tests/components/unifi/test_device_tracker.py | 2 +- 8 files changed, 57 insertions(+), 79 deletions(-) diff --git a/homeassistant/components/unifi/button.py b/homeassistant/components/unifi/button.py index 0235f6156cc3ad..7471675123aba7 100644 --- a/homeassistant/components/unifi/button.py +++ b/homeassistant/components/unifi/button.py @@ -24,7 +24,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -87,13 +86,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up button platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiButtonEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiButtonEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/controller.py b/homeassistant/components/unifi/controller.py index ba188f80135c97..9f965b424ffd39 100644 --- a/homeassistant/components/unifi/controller.py +++ b/homeassistant/components/unifi/controller.py @@ -21,14 +21,9 @@ 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, - entity_registry as er, -) +from homeassistant.helpers import aiohttp_client, device_registry as dr from homeassistant.helpers.device_registry import ( DeviceEntry, DeviceEntryType, @@ -39,13 +34,11 @@ 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 from .const import ( ATTR_MANUFACTURER, - BLOCK_SWITCH, CONF_ALLOW_BANDWIDTH_SENSORS, CONF_ALLOW_UPTIME_SENSORS, CONF_BLOCK_CLIENT, @@ -162,6 +155,24 @@ def host(self) -> str: host: str = self.config_entry.data[CONF_HOST] return host + @callback + @staticmethod + def register_platform( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, + entity_class: type[UnifiEntity], + descriptions: tuple[UnifiEntityDescription, ...], + requires_admin: bool = False, + ) -> None: + """Register platform for UniFi entity management.""" + controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] + if requires_admin and not controller.is_admin: + return + controller.register_platform_add_entities( + entity_class, descriptions, async_add_entities + ) + @callback def register_platform_add_entities( self, @@ -251,30 +262,9 @@ async def initialize(self) -> None: assert self.config_entry.unique_id is not None self.is_admin = self.api.sites[self.config_entry.unique_id].role == "admin" - # Restore clients that are not a part of active clients list. - 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: - mac = entry.unique_id.split("-", 1)[0] - elif entry.domain == Platform.SWITCH and entry.unique_id.startswith( - BLOCK_SWITCH - ): - mac = entry.unique_id.split("-", 1)[1] - else: - continue - - if mac in self.api.clients or mac not in self.api.clients_all: - continue - - client = self.api.clients_all[mac] - self.api.clients.process_raw([dict(client.raw)]) - LOGGER.debug( - "Restore disconnected client %s (%s)", - entry.entity_id, - client.mac, - ) + for mac in self.option_block_clients: + 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)]) self.wireless_clients.update_clients(set(self.api.clients.values())) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index fcfe71a2858246..2b7ac04cc0d80f 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -24,7 +24,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -206,9 +205,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up device tracker for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiScannerEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiScannerEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/image.py b/homeassistant/components/unifi/image.py index 8231b87ee85ba8..2318702f0d15b0 100644 --- a/homeassistant/components/unifi/image.py +++ b/homeassistant/components/unifi/image.py @@ -20,7 +20,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -83,13 +82,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up image platform for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - controller.register_platform_add_entities( - UnifiImageEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiImageEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/sensor.py b/homeassistant/components/unifi/sensor.py index 7cb0b2bbfe3d6e..86c6b0d6352ca4 100644 --- a/homeassistant/components/unifi/sensor.py +++ b/homeassistant/components/unifi/sensor.py @@ -35,7 +35,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.util.dt as dt_util -from .const import DOMAIN as UNIFI_DOMAIN from .controller import UniFiController from .entity import ( HandlerT, @@ -329,9 +328,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up sensors for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiSensorEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, config_entry, async_add_entities, UnifiSensorEntity, ENTITY_DESCRIPTIONS ) diff --git a/homeassistant/components/unifi/switch.py b/homeassistant/components/unifi/switch.py index 560e150e63c242..0aa399146868ef 100644 --- a/homeassistant/components/unifi/switch.py +++ b/homeassistant/components/unifi/switch.py @@ -43,7 +43,7 @@ from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import ATTR_MANUFACTURER, DOMAIN as UNIFI_DOMAIN +from .const import ATTR_MANUFACTURER from .controller import UniFiController from .entity import ( HandlerT, @@ -320,19 +320,13 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up switches for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - - if not controller.is_admin: - return - - for mac in controller.option_block_clients: - if mac not in controller.api.clients and mac in controller.api.clients_all: - controller.api.clients.process_raw( - [dict(controller.api.clients_all[mac].raw)] - ) - - controller.register_platform_add_entities( - UnifiSwitchEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiSwitchEntity, + ENTITY_DESCRIPTIONS, + requires_admin=True, ) diff --git a/homeassistant/components/unifi/update.py b/homeassistant/components/unifi/update.py index 6526a02da838ee..65b26736cf1d37 100644 --- a/homeassistant/components/unifi/update.py +++ b/homeassistant/components/unifi/update.py @@ -4,7 +4,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import TYPE_CHECKING, Any, Generic, TypeVar +from typing import Any, Generic, TypeVar import aiounifi from aiounifi.interfaces.api_handlers import ItemEvent @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN as UNIFI_DOMAIN +from .controller import UniFiController from .entity import ( UnifiEntity, UnifiEntityDescription, @@ -29,9 +29,6 @@ async_device_device_info_fn, ) -if TYPE_CHECKING: - from .controller import UniFiController - LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT", bound=Device) @@ -88,9 +85,12 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up update entities for UniFi Network integration.""" - controller: UniFiController = hass.data[UNIFI_DOMAIN][config_entry.entry_id] - controller.register_platform_add_entities( - UnifiDeviceUpdateEntity, ENTITY_DESCRIPTIONS, async_add_entities + UniFiController.register_platform( + hass, + config_entry, + async_add_entities, + UnifiDeviceUpdateEntity, + ENTITY_DESCRIPTIONS, ) diff --git a/tests/components/unifi/test_device_tracker.py b/tests/components/unifi/test_device_tracker.py index 7b939077e48376..99874b3a949f15 100644 --- a/tests/components/unifi/test_device_tracker.py +++ b/tests/components/unifi/test_device_tracker.py @@ -946,7 +946,7 @@ async def test_restoring_client( await setup_unifi_integration( hass, aioclient_mock, - options={CONF_BLOCK_CLIENT: True}, + options={CONF_BLOCK_CLIENT: [restored["mac"]]}, clients_response=[client], clients_all_response=[restored, not_restored], ) From 9c65e59cc89101aea8c788b34230d1a62455b91d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Mon, 11 Sep 2023 23:46:59 +0900 Subject: [PATCH 341/640] Remove AEMET daily precipitation sensor test (#100118) --- tests/components/aemet/test_sensor.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 4d61dde34fc0f7..8237987bf44705 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -6,7 +6,6 @@ ATTR_CONDITION_PARTLYCLOUDY, ATTR_CONDITION_SNOWY, ) -from homeassistant.const import STATE_UNKNOWN from homeassistant.core import HomeAssistant import homeassistant.util.dt as dt_util @@ -26,9 +25,6 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_daily_forecast_condition") assert state.state == ATTR_CONDITION_PARTLYCLOUDY - state = hass.states.get("sensor.aemet_daily_forecast_precipitation") - assert state.state == STATE_UNKNOWN - state = hass.states.get("sensor.aemet_daily_forecast_precipitation_probability") assert state.state == "30" From 6ccb74997c8080cf691873d85a05bc17356633ae Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 16:58:56 +0200 Subject: [PATCH 342/640] Fix ScrapeSensor.async_added_to_hass (#100125) --- homeassistant/components/scrape/sensor.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/components/scrape/sensor.py b/homeassistant/components/scrape/sensor.py index 77131ccb22504e..bb8c233983d360 100644 --- a/homeassistant/components/scrape/sensor.py +++ b/homeassistant/components/scrape/sensor.py @@ -192,9 +192,7 @@ def _extract_value(self) -> Any: async def async_added_to_hass(self) -> None: """Ensure the data from the initial update is reflected in the state.""" - await ManualTriggerEntity.async_added_to_hass(self) - # https://github.com/python/mypy/issues/15097 - await CoordinatorEntity.async_added_to_hass(self) # type: ignore[arg-type] + await super().async_added_to_hass() self._async_update_from_rest_data() def _async_update_from_rest_data(self) -> None: From 56678851af9802d8dab431e9aeea25f71ed70eb0 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Mon, 11 Sep 2023 18:03:22 +0200 Subject: [PATCH 343/640] Fix inverse naming of function in Reolink (#100113) --- homeassistant/components/reolink/config_flow.py | 4 ++-- homeassistant/components/reolink/util.py | 9 +++------ 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/reolink/config_flow.py b/homeassistant/components/reolink/config_flow.py index d924f395c509c4..e86da1f23a7d34 100644 --- a/homeassistant/components/reolink/config_flow.py +++ b/homeassistant/components/reolink/config_flow.py @@ -19,7 +19,7 @@ from .const import CONF_PROTOCOL, CONF_USE_HTTPS, DOMAIN from .exceptions import ReolinkException, ReolinkWebhookException, UserNotAdmin from .host import ReolinkHost -from .util import has_connection_problem +from .util import is_connected _LOGGER = logging.getLogger(__name__) @@ -103,7 +103,7 @@ async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowRes and CONF_PASSWORD in existing_entry.data and existing_entry.data[CONF_HOST] != discovery_info.ip ): - if has_connection_problem(self.hass, existing_entry): + if is_connected(self.hass, existing_entry): _LOGGER.debug( "Reolink DHCP reported new IP '%s', " "but connection to camera seems to be okay, so sticking to IP '%s'", diff --git a/homeassistant/components/reolink/util.py b/homeassistant/components/reolink/util.py index 2ab625647a7c28..cc9ad192bc3a1a 100644 --- a/homeassistant/components/reolink/util.py +++ b/homeassistant/components/reolink/util.py @@ -8,16 +8,13 @@ from .const import DOMAIN -def has_connection_problem( - hass: HomeAssistant, config_entry: config_entries.ConfigEntry -) -> bool: - """Check if a existing entry has a connection problem.""" +def is_connected(hass: HomeAssistant, config_entry: config_entries.ConfigEntry) -> bool: + """Check if an existing entry has a proper connection.""" reolink_data: ReolinkData | None = hass.data.get(DOMAIN, {}).get( config_entry.entry_id ) - connection_problem = ( + return ( reolink_data is not None and config_entry.state == config_entries.ConfigEntryState.LOADED and reolink_data.device_coordinator.last_update_success ) - return connection_problem From 18e08bc79f6970d0fb8645384970861f10c7dfbe Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Mon, 11 Sep 2023 18:35:48 +0200 Subject: [PATCH 344/640] Bump hatasmota to 0.7.2 (#100129) --- .../components/tasmota/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_light.py | 32 +++++++++++++++++++ tests/components/tasmota/test_switch.py | 32 +++++++++++++++++++ 5 files changed, 67 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index 9843f64fc25b4d..fa34665cd737eb 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.1"] + "requirements": ["HATasmota==0.7.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c588d7b952371d..a31e45c47cc0e9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.1 +HATasmota==0.7.2 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 341901b845a560..7a0eb6c1e25abe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.1 +HATasmota==0.7.2 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_light.py b/tests/components/tasmota/test_light.py index 5c8339a6f8933c..82fa89c528013b 100644 --- a/tests/components/tasmota/test_light.py +++ b/tests/components/tasmota/test_light.py @@ -1835,3 +1835,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.LIGHT, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of lights when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Light 1" + config["fn"][0] = "Light 1" + config["fn"][1] = "Light 2" + config["rl"][0] = 2 + config["rl"][1] = 2 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("light.light_1") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1" + + state = hass.states.get("light.light_1_light_2") + assert state is not None + assert state.attributes["friendly_name"] == "Light 1 Light 2" diff --git a/tests/components/tasmota/test_switch.py b/tests/components/tasmota/test_switch.py index b8d0ed2d060cf0..54d94b46fe89d1 100644 --- a/tests/components/tasmota/test_switch.py +++ b/tests/components/tasmota/test_switch.py @@ -283,3 +283,35 @@ async def test_entity_id_update_discovery_update( await help_test_entity_id_update_discovery_update( hass, mqtt_mock, Platform.SWITCH, config ) + + +async def test_no_device_name( + hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota +) -> None: + """Test name of switches when no device name is set. + + When the device name is not set, Tasmota uses friendly name 1 as device naem. + This test ensures that case is handled correctly. + """ + config = copy.deepcopy(DEFAULT_CONFIG) + config["dn"] = "Relay 1" + config["fn"][0] = "Relay 1" + config["fn"][1] = "Relay 2" + config["rl"][0] = 1 + config["rl"][1] = 1 + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + + state = hass.states.get("switch.relay_1") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1" + + state = hass.states.get("switch.relay_1_relay_2") + assert state is not None + assert state.attributes["friendly_name"] == "Relay 1 Relay 2" From 0fe88d60acd64956c896aa72555c3b91faeddc70 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 11:39:10 -0500 Subject: [PATCH 345/640] Guard expensive debug logging with isEnabledFor in alexa (#100137) --- homeassistant/components/alexa/state_report.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa/state_report.py b/homeassistant/components/alexa/state_report.py index 786b2ee52275dc..f1cf13a0a7ee90 100644 --- a/homeassistant/components/alexa/state_report.py +++ b/homeassistant/components/alexa/state_report.py @@ -378,8 +378,9 @@ async def async_send_changereport_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return @@ -531,8 +532,9 @@ async def async_send_doorbell_event_message( response_text = await response.text() - _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) - _LOGGER.debug("Received (%s): %s", response.status, response_text) + if _LOGGER.isEnabledFor(logging.DEBUG): + _LOGGER.debug("Sent: %s", json.dumps(message_serialized)) + _LOGGER.debug("Received (%s): %s", response.status, response_text) if response.status == HTTPStatus.ACCEPTED: return From 17db20fdd7f6eb5235e3d29cf8211f6d2a2d9560 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Mon, 11 Sep 2023 10:06:55 -0700 Subject: [PATCH 346/640] Add Apple WeatherKit integration (#99895) --- CODEOWNERS | 2 + homeassistant/brands/apple.json | 3 +- .../components/weatherkit/__init__.py | 62 + .../components/weatherkit/config_flow.py | 126 + homeassistant/components/weatherkit/const.py | 13 + .../components/weatherkit/coordinator.py | 70 + .../components/weatherkit/manifest.json | 9 + .../components/weatherkit/strings.json | 25 + .../components/weatherkit/weather.py | 249 + homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/weatherkit/__init__.py | 71 + tests/components/weatherkit/conftest.py | 14 + .../weatherkit/fixtures/weather_response.json | 6344 +++++++++++++++++ .../weatherkit/snapshots/test_weather.ambr | 4087 +++++++++++ .../components/weatherkit/test_config_flow.py | 134 + .../components/weatherkit/test_coordinator.py | 32 + tests/components/weatherkit/test_setup.py | 63 + tests/components/weatherkit/test_weather.py | 115 + 21 files changed, 11431 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/weatherkit/__init__.py create mode 100644 homeassistant/components/weatherkit/config_flow.py create mode 100644 homeassistant/components/weatherkit/const.py create mode 100644 homeassistant/components/weatherkit/coordinator.py create mode 100644 homeassistant/components/weatherkit/manifest.json create mode 100644 homeassistant/components/weatherkit/strings.json create mode 100644 homeassistant/components/weatherkit/weather.py create mode 100644 tests/components/weatherkit/__init__.py create mode 100644 tests/components/weatherkit/conftest.py create mode 100644 tests/components/weatherkit/fixtures/weather_response.json create mode 100644 tests/components/weatherkit/snapshots/test_weather.ambr create mode 100644 tests/components/weatherkit/test_config_flow.py create mode 100644 tests/components/weatherkit/test_coordinator.py create mode 100644 tests/components/weatherkit/test_setup.py create mode 100644 tests/components/weatherkit/test_weather.py diff --git a/CODEOWNERS b/CODEOWNERS index 8a454cf775a533..29c744ce42e9a1 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1404,6 +1404,8 @@ build.json @home-assistant/supervisor /tests/components/waze_travel_time/ @eifinger /homeassistant/components/weather/ @home-assistant/core /tests/components/weather/ @home-assistant/core +/homeassistant/components/weatherkit/ @tjhorner +/tests/components/weatherkit/ @tjhorner /homeassistant/components/webhook/ @home-assistant/core /tests/components/webhook/ @home-assistant/core /homeassistant/components/webostv/ @thecode diff --git a/homeassistant/brands/apple.json b/homeassistant/brands/apple.json index 00f646e435ec7c..b0b66de0bccafa 100644 --- a/homeassistant/brands/apple.json +++ b/homeassistant/brands/apple.json @@ -7,6 +7,7 @@ "homekit", "ibeacon", "icloud", - "itunes" + "itunes", + "weatherkit" ] } diff --git a/homeassistant/components/weatherkit/__init__.py b/homeassistant/components/weatherkit/__init__.py new file mode 100644 index 00000000000000..fb41ffc108440a --- /dev/null +++ b/homeassistant/components/weatherkit/__init__.py @@ -0,0 +1,62 @@ +"""Integration for Apple's WeatherKit API.""" +from __future__ import annotations + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) + +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 ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) +from .coordinator import WeatherKitDataUpdateCoordinator + +PLATFORMS: list[Platform] = [Platform.WEATHER] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + hass.data.setdefault(DOMAIN, {}) + coordinator = WeatherKitDataUpdateCoordinator( + hass=hass, + client=WeatherKitApiClient( + key_id=entry.data[CONF_KEY_ID], + service_id=entry.data[CONF_SERVICE_ID], + team_id=entry.data[CONF_TEAM_ID], + key_pem=entry.data[CONF_KEY_PEM], + session=async_get_clientsession(hass), + ), + ) + + try: + await coordinator.update_supported_data_sets() + except WeatherKitApiClientAuthenticationError as ex: + LOGGER.error("Authentication error initializing integration: %s", ex) + return False + except WeatherKitApiClientError as ex: + raise ConfigEntryNotReady from ex + + 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 + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unloaded := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + hass.data[DOMAIN].pop(entry.entry_id) + return unloaded diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py new file mode 100644 index 00000000000000..d9db70dde114a5 --- /dev/null +++ b/homeassistant/components/weatherkit/config_flow.py @@ -0,0 +1,126 @@ +"""Adds config flow for WeatherKit.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +from apple_weatherkit.client import ( + WeatherKitApiClient, + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.selector import ( + LocationSelector, + LocationSelectorConfig, + TextSelector, + TextSelectorConfig, +) + +from .const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, + LOGGER, +) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_LOCATION): LocationSelector( + LocationSelectorConfig(radius=False, icon="") + ), + # Auth + vol.Required(CONF_KEY_ID): str, + vol.Required(CONF_SERVICE_ID): str, + vol.Required(CONF_TEAM_ID): str, + vol.Required(CONF_KEY_PEM): TextSelector( + TextSelectorConfig( + multiline=True, + ) + ), + } +) + + +class WeatherKitUnsupportedLocationError(Exception): + """Error to indicate a location is unsupported.""" + + +class WeatherKitFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for WeatherKit.""" + + VERSION = 1 + + async def async_step_user( + self, + user_input: dict[str, Any] | None = None, + ) -> data_entry_flow.FlowResult: + """Handle a flow initialized by the user.""" + errors = {} + if user_input is not None: + try: + await self._test_config(user_input) + except WeatherKitUnsupportedLocationError as exception: + LOGGER.error(exception) + errors["base"] = "unsupported_location" + except WeatherKitApiClientAuthenticationError as exception: + LOGGER.warning(exception) + errors["base"] = "invalid_auth" + except WeatherKitApiClientCommunicationError as exception: + LOGGER.error(exception) + errors["base"] = "cannot_connect" + except WeatherKitApiClientError as exception: + LOGGER.exception(exception) + errors["base"] = "unknown" + else: + # Flatten location + location = user_input.pop(CONF_LOCATION) + user_input[CONF_LATITUDE] = location[CONF_LATITUDE] + user_input[CONF_LONGITUDE] = location[CONF_LONGITUDE] + + return self.async_create_entry( + title=f"{user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}", + data=user_input, + ) + + suggested_values: Mapping[str, Any] = { + CONF_LOCATION: { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + } + + data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, suggested_values) + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) + + async def _test_config(self, user_input: dict[str, Any]) -> None: + """Validate credentials.""" + client = WeatherKitApiClient( + key_id=user_input[CONF_KEY_ID], + service_id=user_input[CONF_SERVICE_ID], + team_id=user_input[CONF_TEAM_ID], + key_pem=user_input[CONF_KEY_PEM], + session=async_get_clientsession(self.hass), + ) + + location = user_input[CONF_LOCATION] + availability = await client.get_availability( + location[CONF_LATITUDE], + location[CONF_LONGITUDE], + ) + + if len(availability) == 0: + raise WeatherKitUnsupportedLocationError( + "API does not support this location" + ) diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py new file mode 100644 index 00000000000000..f2ef7e4c7202db --- /dev/null +++ b/homeassistant/components/weatherkit/const.py @@ -0,0 +1,13 @@ +"""Constants for WeatherKit.""" +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +NAME = "Apple WeatherKit" +DOMAIN = "weatherkit" +ATTRIBUTION = "Data provided by Apple Weather. https://developer.apple.com/weatherkit/data-source-attribution/" + +CONF_KEY_ID = "key_id" +CONF_SERVICE_ID = "service_id" +CONF_TEAM_ID = "team_id" +CONF_KEY_PEM = "key_pem" diff --git a/homeassistant/components/weatherkit/coordinator.py b/homeassistant/components/weatherkit/coordinator.py new file mode 100644 index 00000000000000..a918ce0f850d03 --- /dev/null +++ b/homeassistant/components/weatherkit/coordinator.py @@ -0,0 +1,70 @@ +"""DataUpdateCoordinator for WeatherKit integration.""" +from __future__ import annotations + +from datetime import timedelta + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import WeatherKitApiClient, WeatherKitApiClientError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, LOGGER + +REQUESTED_DATA_SETS = [ + DataSetType.CURRENT_WEATHER, + DataSetType.DAILY_FORECAST, + DataSetType.HOURLY_FORECAST, +] + + +class WeatherKitDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching data from the API.""" + + config_entry: ConfigEntry + supported_data_sets: list[DataSetType] | None = None + + def __init__( + self, + hass: HomeAssistant, + client: WeatherKitApiClient, + ) -> None: + """Initialize.""" + self.client = client + super().__init__( + hass=hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=15), + ) + + async def update_supported_data_sets(self): + """Obtain the supported data sets for this location and store them.""" + supported_data_sets = await self.client.get_availability( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + ) + + self.supported_data_sets = [ + data_set + for data_set in REQUESTED_DATA_SETS + if data_set in supported_data_sets + ] + + LOGGER.debug("Supported data sets: %s", self.supported_data_sets) + + async def _async_update_data(self): + """Update the current weather and forecasts.""" + try: + if not self.supported_data_sets: + await self.update_supported_data_sets() + + return await self.client.get_weather_data( + self.config_entry.data[CONF_LATITUDE], + self.config_entry.data[CONF_LONGITUDE], + self.supported_data_sets, + ) + except WeatherKitApiClientError as exception: + raise UpdateFailed(exception) from exception diff --git a/homeassistant/components/weatherkit/manifest.json b/homeassistant/components/weatherkit/manifest.json new file mode 100644 index 00000000000000..984e36483c7ff8 --- /dev/null +++ b/homeassistant/components/weatherkit/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "weatherkit", + "name": "Apple WeatherKit", + "codeowners": ["@tjhorner"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/weatherkit", + "iot_class": "cloud_polling", + "requirements": ["apple_weatherkit==1.0.1"] +} diff --git a/homeassistant/components/weatherkit/strings.json b/homeassistant/components/weatherkit/strings.json new file mode 100644 index 00000000000000..4581028f209e98 --- /dev/null +++ b/homeassistant/components/weatherkit/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "WeatherKit setup", + "description": "Enter your location details and WeatherKit authentication credentials below.", + "data": { + "name": "Name", + "location": "[%key:common::config_flow::data::location%]", + "key_id": "Key ID", + "team_id": "Apple team ID", + "service_id": "Service ID", + "key_pem": "Private key (.p8)" + } + } + }, + "error": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]", + "unsupported_location": "Apple WeatherKit does not provide data for this location.", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + } +} diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py new file mode 100644 index 00000000000000..fc6b0dac1cba2d --- /dev/null +++ b/homeassistant/components/weatherkit/weather.py @@ -0,0 +1,249 @@ +"""Weather entity for Apple WeatherKit integration.""" + +from typing import Any, cast + +from apple_weatherkit import DataSetType + +from homeassistant.components.weather import ( + Forecast, + SingleCoordinatorWeatherEntity, + WeatherEntityFeature, +) +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 .coordinator import WeatherKitDataUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Add a weather entity from a config_entry.""" + coordinator: WeatherKitDataUpdateCoordinator = hass.data[DOMAIN][ + config_entry.entry_id + ] + + async_add_entities([WeatherKitWeather(coordinator)]) + + +condition_code_to_hass = { + "BlowingDust": "windy", + "Clear": "sunny", + "Cloudy": "cloudy", + "Foggy": "fog", + "Haze": "fog", + "MostlyClear": "sunny", + "MostlyCloudy": "cloudy", + "PartlyCloudy": "partlycloudy", + "Smoky": "fog", + "Breezy": "windy", + "Windy": "windy", + "Drizzle": "rainy", + "HeavyRain": "pouring", + "IsolatedThunderstorms": "lightning", + "Rain": "rainy", + "SunShowers": "rainy", + "ScatteredThunderstorms": "lightning", + "StrongStorms": "lightning", + "Thunderstorms": "lightning", + "Frigid": "snowy", + "Hail": "hail", + "Hot": "sunny", + "Flurries": "snowy", + "Sleet": "snowy", + "Snow": "snowy", + "SunFlurries": "snowy", + "WintryMix": "snowy", + "Blizzard": "snowy", + "BlowingSnow": "snowy", + "FreezingDrizzle": "snowy-rainy", + "FreezingRain": "snowy-rainy", + "HeavySnow": "snowy", + "Hurricane": "exceptional", + "TropicalStorm": "exceptional", +} + + +def _map_daily_forecast(forecast) -> Forecast: + return { + "datetime": forecast.get("forecastStart"), + "condition": condition_code_to_hass[forecast.get("conditionCode")], + "native_temperature": forecast.get("temperatureMax"), + "native_templow": forecast.get("temperatureMin"), + "native_precipitation": forecast.get("precipitationAmount"), + "precipitation_probability": forecast.get("precipitationChance") * 100, + "uv_index": forecast.get("maxUvIndex"), + } + + +def _map_hourly_forecast(forecast) -> Forecast: + return { + "datetime": forecast.get("forecastStart"), + "condition": condition_code_to_hass[forecast.get("conditionCode")], + "native_temperature": forecast.get("temperature"), + "native_apparent_temperature": forecast.get("temperatureApparent"), + "native_dew_point": forecast.get("temperatureDewPoint"), + "native_pressure": forecast.get("pressure"), + "native_wind_gust_speed": forecast.get("windGust"), + "native_wind_speed": forecast.get("windSpeed"), + "wind_bearing": forecast.get("windDirection"), + "humidity": forecast.get("humidity") * 100, + "native_precipitation": forecast.get("precipitationAmount"), + "precipitation_probability": forecast.get("precipitationChance") * 100, + "cloud_coverage": forecast.get("cloudCover") * 100, + "uv_index": forecast.get("uvIndex"), + } + + +class WeatherKitWeather( + SingleCoordinatorWeatherEntity[WeatherKitDataUpdateCoordinator] +): + """Weather entity for Apple WeatherKit integration.""" + + _attr_attribution = ATTRIBUTION + + _attr_has_entity_name = True + _attr_name = None + + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.MBAR + _attr_native_visibility_unit = UnitOfLength.KILOMETERS + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + + def __init__( + self, + coordinator: WeatherKitDataUpdateCoordinator, + ) -> None: + """Initialise the platform with a data instance and site.""" + 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", + ) + + @property + def supported_features(self) -> WeatherEntityFeature: + """Determine supported features based on available data sets reported by WeatherKit.""" + if not self.coordinator.supported_data_sets: + return WeatherEntityFeature(0) + + features = WeatherEntityFeature(0) + if DataSetType.DAILY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_DAILY + if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets: + features |= WeatherEntityFeature.FORECAST_HOURLY + return features + + @property + def data(self) -> dict[str, Any]: + """Return coordinator data.""" + return self.coordinator.data + + @property + def current_weather(self) -> dict[str, Any]: + """Return current weather data.""" + return self.data["currentWeather"] + + @property + def condition(self) -> str | None: + """Return the current condition.""" + condition_code = cast(str, self.current_weather.get("conditionCode")) + condition = condition_code_to_hass[condition_code] + + if condition == "sunny" and self.current_weather.get("daylight") is False: + condition = "clear-night" + + return condition + + @property + def native_temperature(self) -> float | None: + """Return the current temperature.""" + return self.current_weather.get("temperature") + + @property + def native_apparent_temperature(self) -> float | None: + """Return the current apparent_temperature.""" + return self.current_weather.get("temperatureApparent") + + @property + def native_dew_point(self) -> float | None: + """Return the current dew_point.""" + return self.current_weather.get("temperatureDewPoint") + + @property + def native_pressure(self) -> float | None: + """Return the current pressure.""" + return self.current_weather.get("pressure") + + @property + def humidity(self) -> float | None: + """Return the current humidity.""" + return cast(float, self.current_weather.get("humidity")) * 100 + + @property + def cloud_coverage(self) -> float | None: + """Return the current cloud_coverage.""" + return cast(float, self.current_weather.get("cloudCover")) * 100 + + @property + def uv_index(self) -> float | None: + """Return the current uv_index.""" + return self.current_weather.get("uvIndex") + + @property + def native_visibility(self) -> float | None: + """Return the current visibility.""" + return cast(float, self.current_weather.get("visibility")) / 1000 + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the current wind_gust_speed.""" + return self.current_weather.get("windGust") + + @property + def native_wind_speed(self) -> float | None: + """Return the current wind_speed.""" + return self.current_weather.get("windSpeed") + + @property + def wind_bearing(self) -> float | None: + """Return the current wind_bearing.""" + return self.current_weather.get("windDirection") + + @callback + def _async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast.""" + daily_forecast = self.data.get("forecastDaily") + if not daily_forecast: + return None + + forecast = daily_forecast.get("days") + return [_map_daily_forecast(f) for f in forecast] + + @callback + def _async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast.""" + hourly_forecast = self.data.get("forecastHourly") + if not hourly_forecast: + return None + + forecast = hourly_forecast.get("hours") + return [_map_hourly_forecast(f) for f in forecast] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0f55df7cc99b90..1557df8f33b21a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -519,6 +519,7 @@ "waqi", "watttime", "waze_travel_time", + "weatherkit", "webostv", "wemo", "whirlpool", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 5eaf1b8d0a4d18..7cad78a49fcb19 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -335,6 +335,12 @@ "config_flow": false, "iot_class": "local_polling", "name": "Apple iTunes" + }, + "weatherkit": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "Apple WeatherKit" } } }, diff --git a/requirements_all.txt b/requirements_all.txt index a31e45c47cc0e9..4f22107c76f7fb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -423,6 +423,9 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.1 + # homeassistant.components.apprise apprise==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7a0eb6c1e25abe..9df6f6b1a11f8d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -389,6 +389,9 @@ anthemav==1.4.1 # homeassistant.components.apcupsd apcaccess==0.0.13 +# homeassistant.components.weatherkit +apple_weatherkit==1.0.1 + # homeassistant.components.apprise apprise==1.4.5 diff --git a/tests/components/weatherkit/__init__.py b/tests/components/weatherkit/__init__.py new file mode 100644 index 00000000000000..5118c44c45be80 --- /dev/null +++ b/tests/components/weatherkit/__init__.py @@ -0,0 +1,71 @@ +"""Tests for the Apple WeatherKit integration.""" +from unittest.mock import patch + +from apple_weatherkit import DataSetType + +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry, load_json_object_fixture + +EXAMPLE_CONFIG_DATA = { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def init_integration( + hass: HomeAssistant, + is_night_time: bool = False, + has_hourly_forecast: bool = True, + has_daily_forecast: bool = True, +) -> MockConfigEntry: + """Set up the WeatherKit integration in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + weather_response = load_json_object_fixture("weatherkit/weather_response.json") + + available_data_sets = [DataSetType.CURRENT_WEATHER] + + if is_night_time: + weather_response["currentWeather"]["daylight"] = False + weather_response["currentWeather"]["conditionCode"] = "Clear" + + if not has_daily_forecast: + del weather_response["forecastDaily"] + else: + available_data_sets.append(DataSetType.DAILY_FORECAST) + + if not has_hourly_forecast: + del weather_response["forecastHourly"] + else: + available_data_sets.append(DataSetType.HOURLY_FORECAST) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + return_value=weather_response, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=available_data_sets, + ): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/weatherkit/conftest.py b/tests/components/weatherkit/conftest.py new file mode 100644 index 00000000000000..7cfa2f7eef537a --- /dev/null +++ b/tests/components/weatherkit/conftest.py @@ -0,0 +1,14 @@ +"""Common fixtures for the Apple WeatherKit tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.weatherkit.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/weatherkit/fixtures/weather_response.json b/tests/components/weatherkit/fixtures/weather_response.json new file mode 100644 index 00000000000000..c2d619d85d8c97 --- /dev/null +++ b/tests/components/weatherkit/fixtures/weather_response.json @@ -0,0 +1,6344 @@ +{ + "currentWeather": { + "name": "CurrentWeather", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T22:08:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "asOf": "2023-09-08T22:03:04Z", + "cloudCover": 0.62, + "cloudCoverLowAltPct": 0.35, + "cloudCoverMidAltPct": 0.22, + "cloudCoverHighAltPct": 0.32, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationIntensity": 0.0, + "pressure": 1009.8, + "pressureTrend": "rising", + "temperature": 22.9, + "temperatureApparent": 24.92, + "temperatureDewPoint": 21.28, + "uvIndex": 1, + "visibility": 20965.22, + "windDirection": 259, + "windGust": 10.53, + "windSpeed": 5.23 + }, + "forecastDaily": { + "name": "DailyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "days": [ + { + "forecastStart": "2023-09-08T15:00:00Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonset": "2023-09-09T06:10:26Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-09T14:54:36Z", + "solarNoon": "2023-09-09T02:54:26Z", + "sunrise": "2023-09-08T20:34:47Z", + "sunriseCivil": "2023-09-08T20:09:00Z", + "sunriseNautical": "2023-09-08T19:38:47Z", + "sunriseAstronomical": "2023-09-08T19:07:36Z", + "sunset": "2023-09-09T09:13:58Z", + "sunsetCivil": "2023-09-09T09:39:40Z", + "sunsetNautical": "2023-09-09T10:09:52Z", + "sunsetAstronomical": "2023-09-09T10:40:54Z", + "temperatureMax": 28.62, + "temperatureMin": 21.18, + "daytimeForecast": { + "forecastStart": "2023-09-08T22:00:00Z", + "forecastEnd": "2023-09-09T10:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 318, + "windSpeed": 7.36 + }, + "overnightForecast": { + "forecastStart": "2023-09-09T10:00:00Z", + "forecastEnd": "2023-09-09T22:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 2.99 + }, + "restOfDayForecast": { + "forecastStart": "2023-09-08T22:03:04Z", + "forecastEnd": "2023-09-09T15:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 315, + "windSpeed": 5.78 + } + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "forecastEnd": "2023-09-10T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-09T15:36:16Z", + "moonset": "2023-09-10T06:54:57Z", + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-10T14:54:15Z", + "solarNoon": "2023-09-10T02:54:05Z", + "sunrise": "2023-09-09T20:35:31Z", + "sunriseCivil": "2023-09-09T20:09:47Z", + "sunriseNautical": "2023-09-09T19:39:37Z", + "sunriseAstronomical": "2023-09-09T19:08:32Z", + "sunset": "2023-09-10T09:12:31Z", + "sunsetCivil": "2023-09-10T09:38:11Z", + "sunsetNautical": "2023-09-10T10:08:20Z", + "sunsetAstronomical": "2023-09-10T10:39:18Z", + "temperatureMax": 30.64, + "temperatureMin": 21.0, + "daytimeForecast": { + "forecastStart": "2023-09-09T22:00:00Z", + "forecastEnd": "2023-09-10T10:00:00Z", + "cloudCover": 0.76, + "conditionCode": "Rain", + "humidity": 0.73, + "precipitationAmount": 3.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 96, + "windSpeed": 4.94 + }, + "overnightForecast": { + "forecastStart": "2023-09-10T10:00:00Z", + "forecastEnd": "2023-09-10T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 141, + "windSpeed": 7.84 + } + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "forecastEnd": "2023-09-11T15:00:00Z", + "conditionCode": "MostlyCloudy", + "maxUvIndex": 6, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-10T16:34:55Z", + "moonset": "2023-09-11T07:32:40Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-11T14:53:54Z", + "solarNoon": "2023-09-11T02:53:44Z", + "sunrise": "2023-09-10T20:36:16Z", + "sunriseCivil": "2023-09-10T20:10:33Z", + "sunriseNautical": "2023-09-10T19:40:27Z", + "sunriseAstronomical": "2023-09-10T19:09:28Z", + "sunset": "2023-09-11T09:11:04Z", + "sunsetCivil": "2023-09-11T09:36:43Z", + "sunsetNautical": "2023-09-11T10:06:47Z", + "sunsetAstronomical": "2023-09-11T10:37:41Z", + "temperatureMax": 30.44, + "temperatureMin": 23.14, + "daytimeForecast": { + "forecastStart": "2023-09-10T22:00:00Z", + "forecastEnd": "2023-09-11T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 139, + "windSpeed": 14.23 + }, + "overnightForecast": { + "forecastStart": "2023-09-11T10:00:00Z", + "forecastEnd": "2023-09-11T22:00:00Z", + "cloudCover": 0.83, + "conditionCode": "MostlyCloudy", + "humidity": 0.85, + "precipitationAmount": 0.5, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 144, + "windSpeed": 11.26 + } + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "forecastEnd": "2023-09-12T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 5, + "moonPhase": "waningCrescent", + "moonrise": "2023-09-11T17:34:35Z", + "moonset": "2023-09-12T08:04:36Z", + "precipitationAmount": 0.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-12T14:53:33Z", + "solarNoon": "2023-09-12T02:53:22Z", + "sunrise": "2023-09-11T20:37:00Z", + "sunriseCivil": "2023-09-11T20:11:20Z", + "sunriseNautical": "2023-09-11T19:41:16Z", + "sunriseAstronomical": "2023-09-11T19:10:23Z", + "sunset": "2023-09-12T09:09:37Z", + "sunsetCivil": "2023-09-12T09:35:14Z", + "sunsetNautical": "2023-09-12T10:05:15Z", + "sunsetAstronomical": "2023-09-12T10:36:04Z", + "temperatureMax": 30.42, + "temperatureMin": 23.15, + "daytimeForecast": { + "forecastStart": "2023-09-11T22:00:00Z", + "forecastEnd": "2023-09-12T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "Drizzle", + "humidity": 0.72, + "precipitationAmount": 0.2, + "precipitationAmountByType": {}, + "precipitationChance": 0.32, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 140, + "windSpeed": 12.44 + }, + "overnightForecast": { + "forecastStart": "2023-09-12T10:00:00Z", + "forecastEnd": "2023-09-12T22:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.47, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 148, + "windSpeed": 8.78 + } + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "forecastEnd": "2023-09-13T15:00:00Z", + "conditionCode": "Rain", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-12T18:33:48Z", + "moonset": "2023-09-13T08:32:25Z", + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.37, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-13T14:53:11Z", + "solarNoon": "2023-09-13T02:53:01Z", + "sunrise": "2023-09-12T20:37:45Z", + "sunriseCivil": "2023-09-12T20:12:07Z", + "sunriseNautical": "2023-09-12T19:42:05Z", + "sunriseAstronomical": "2023-09-12T19:11:18Z", + "sunset": "2023-09-13T09:08:10Z", + "sunsetCivil": "2023-09-13T09:33:46Z", + "sunsetNautical": "2023-09-13T10:03:43Z", + "sunsetAstronomical": "2023-09-13T10:34:27Z", + "temperatureMax": 30.4, + "temperatureMin": 22.15, + "daytimeForecast": { + "forecastStart": "2023-09-12T22:00:00Z", + "forecastEnd": "2023-09-13T10:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "humidity": 0.7, + "precipitationAmount": 7.7, + "precipitationAmountByType": {}, + "precipitationChance": 0.24, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 70, + "windSpeed": 7.79 + }, + "overnightForecast": { + "forecastStart": "2023-09-13T10:00:00Z", + "forecastEnd": "2023-09-13T22:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 151, + "windSpeed": 5.69 + } + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "forecastEnd": "2023-09-14T15:00:00Z", + "conditionCode": "Drizzle", + "maxUvIndex": 6, + "moonPhase": "new", + "moonrise": "2023-09-13T19:31:58Z", + "moonset": "2023-09-14T08:57:12Z", + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-14T14:52:50Z", + "solarNoon": "2023-09-14T02:52:40Z", + "sunrise": "2023-09-13T20:38:29Z", + "sunriseCivil": "2023-09-13T20:12:53Z", + "sunriseNautical": "2023-09-13T19:42:55Z", + "sunriseAstronomical": "2023-09-13T19:12:12Z", + "sunset": "2023-09-14T09:06:42Z", + "sunsetCivil": "2023-09-14T09:32:17Z", + "sunsetNautical": "2023-09-14T10:02:11Z", + "sunsetAstronomical": "2023-09-14T10:32:51Z", + "temperatureMax": 30.98, + "temperatureMin": 22.62, + "daytimeForecast": { + "forecastStart": "2023-09-13T22:00:00Z", + "forecastEnd": "2023-09-14T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "humidity": 0.71, + "precipitationAmount": 0.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.45, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 11, + "windSpeed": 5.37 + }, + "overnightForecast": { + "forecastStart": "2023-09-14T10:00:00Z", + "forecastEnd": "2023-09-14T22:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 166, + "windSpeed": 5.09 + } + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "forecastEnd": "2023-09-15T15:00:00Z", + "conditionCode": "PartlyCloudy", + "maxUvIndex": 7, + "moonPhase": "new", + "moonrise": "2023-09-14T20:29:10Z", + "moonset": "2023-09-15T09:20:27Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.52, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-15T14:52:28Z", + "solarNoon": "2023-09-15T02:52:18Z", + "sunrise": "2023-09-14T20:39:14Z", + "sunriseCivil": "2023-09-14T20:13:39Z", + "sunriseNautical": "2023-09-14T19:43:43Z", + "sunriseAstronomical": "2023-09-14T19:13:06Z", + "sunset": "2023-09-15T09:05:15Z", + "sunsetCivil": "2023-09-15T09:30:48Z", + "sunsetNautical": "2023-09-15T10:00:39Z", + "sunsetAstronomical": "2023-09-15T10:31:15Z", + "temperatureMax": 31.47, + "temperatureMin": 22.4, + "daytimeForecast": { + "forecastStart": "2023-09-14T22:00:00Z", + "forecastEnd": "2023-09-15T10:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.29, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 356, + "windSpeed": 7.68 + }, + "overnightForecast": { + "forecastStart": "2023-09-15T10:00:00Z", + "forecastEnd": "2023-09-15T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 179, + "windSpeed": 5.46 + } + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "forecastEnd": "2023-09-16T15:00:00Z", + "conditionCode": "MostlyClear", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-15T21:26:00Z", + "moonset": "2023-09-16T09:43:08Z", + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-16T14:52:07Z", + "solarNoon": "2023-09-16T02:51:57Z", + "sunrise": "2023-09-15T20:39:59Z", + "sunriseCivil": "2023-09-15T20:14:26Z", + "sunriseNautical": "2023-09-15T19:44:32Z", + "sunriseAstronomical": "2023-09-15T19:13:59Z", + "sunset": "2023-09-16T09:03:47Z", + "sunsetCivil": "2023-09-16T09:29:19Z", + "sunsetNautical": "2023-09-16T09:59:07Z", + "sunsetAstronomical": "2023-09-16T10:29:39Z", + "temperatureMax": 31.77, + "temperatureMin": 23.29, + "daytimeForecast": { + "forecastStart": "2023-09-15T22:00:00Z", + "forecastEnd": "2023-09-16T10:00:00Z", + "cloudCover": 0.18, + "conditionCode": "MostlyClear", + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 68, + "windSpeed": 6.49 + }, + "overnightForecast": { + "forecastStart": "2023-09-16T10:00:00Z", + "forecastEnd": "2023-09-16T22:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.0, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 158, + "windSpeed": 7.94 + } + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "forecastEnd": "2023-09-17T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 8, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-16T22:23:20Z", + "moonset": "2023-09-17T10:06:21Z", + "precipitationAmount": 5.3, + "precipitationAmountByType": {}, + "precipitationChance": 0.35, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-17T14:51:45Z", + "solarNoon": "2023-09-17T02:51:35Z", + "sunrise": "2023-09-16T20:40:43Z", + "sunriseCivil": "2023-09-16T20:15:12Z", + "sunriseNautical": "2023-09-16T19:45:21Z", + "sunriseAstronomical": "2023-09-16T19:14:53Z", + "sunset": "2023-09-17T09:02:19Z", + "sunsetCivil": "2023-09-17T09:27:50Z", + "sunsetNautical": "2023-09-17T09:57:36Z", + "sunsetAstronomical": "2023-09-17T10:28:03Z", + "temperatureMax": 30.68, + "temperatureMin": 23.21, + "daytimeForecast": { + "forecastStart": "2023-09-16T22:00:00Z", + "forecastEnd": "2023-09-17T10:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "humidity": 0.69, + "precipitationAmount": 3.8, + "precipitationAmountByType": {}, + "precipitationChance": 0.22, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 273, + "windSpeed": 8.43 + }, + "overnightForecast": { + "forecastStart": "2023-09-17T10:00:00Z", + "forecastEnd": "2023-09-17T22:00:00Z", + "cloudCover": 0.52, + "conditionCode": "Thunderstorms", + "humidity": 0.9, + "precipitationAmount": 2.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.43, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 228, + "windSpeed": 4.22 + } + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "forecastEnd": "2023-09-18T15:00:00Z", + "conditionCode": "Thunderstorms", + "maxUvIndex": 6, + "moonPhase": "waxingCrescent", + "moonrise": "2023-09-17T23:22:07Z", + "moonset": "2023-09-18T10:31:34Z", + "precipitationAmount": 2.1, + "precipitationAmountByType": {}, + "precipitationChance": 0.49, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "solarMidnight": "2023-09-18T14:51:24Z", + "solarNoon": "2023-09-18T02:51:14Z", + "sunrise": "2023-09-17T20:41:28Z", + "sunriseCivil": "2023-09-17T20:15:58Z", + "sunriseNautical": "2023-09-17T19:46:09Z", + "sunriseAstronomical": "2023-09-17T19:15:46Z", + "sunset": "2023-09-18T09:00:51Z", + "sunsetCivil": "2023-09-18T09:26:21Z", + "sunsetNautical": "2023-09-18T09:56:06Z", + "sunsetAstronomical": "2023-09-18T10:26:28Z", + "temperatureMax": 28.15, + "temperatureMin": 22.47, + "daytimeForecast": { + "forecastStart": "2023-09-17T22:00:00Z", + "forecastEnd": "2023-09-18T10:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "humidity": 0.73, + "precipitationAmount": 1.6, + "precipitationAmountByType": {}, + "precipitationChance": 0.3, + "precipitationType": "rain", + "snowfallAmount": 0.0, + "windDirection": 336, + "windSpeed": 12.53 + }, + "overnightForecast": { + "forecastStart": "2023-09-18T10:00:00Z", + "forecastEnd": "2023-09-18T22:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationAmountByType": {}, + "precipitationChance": 0.26, + "precipitationType": "clear", + "snowfallAmount": 0.0, + "windDirection": 162, + "windSpeed": 8.23 + } + } + ] + }, + "forecastHourly": { + "name": "HourlyForecast", + "metadata": { + "attributionURL": "https://developer.apple.com/weatherkit/data-source-attribution/", + "expireTime": "2023-09-08T23:03:04Z", + "latitude": 35.47, + "longitude": 135.749, + "readTime": "2023-09-08T22:03:04Z", + "reportedTime": "2023-09-08T21:02:40Z", + "units": "m", + "version": 1 + }, + "hours": [ + { + "forecastStart": "2023-09-08T14:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.55, + "temperatureApparent": 24.61, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 17056.0, + "windDirection": 264, + "windGust": 13.44, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-08T15:00:00Z", + "cloudCover": 0.8, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.24, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.38, + "temperatureApparent": 24.42, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19190.0, + "windDirection": 261, + "windGust": 11.91, + "windSpeed": 6.64 + }, + { + "forecastStart": "2023-09-08T16:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.84, + "temperatureDewPoint": 21.09, + "uvIndex": 0, + "visibility": 17045.0, + "windDirection": 252, + "windGust": 11.15, + "windSpeed": 6.14 + }, + { + "forecastStart": "2023-09-08T17:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.73, + "temperatureApparent": 23.54, + "temperatureDewPoint": 20.93, + "uvIndex": 0, + "visibility": 16267.0, + "windDirection": 248, + "windGust": 11.57, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-08T18:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.57, + "temperatureApparent": 23.32, + "temperatureDewPoint": 20.77, + "uvIndex": 0, + "visibility": 17319.0, + "windDirection": 237, + "windGust": 12.42, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-08T19:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.03, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.33, + "temperatureApparent": 23.01, + "temperatureDewPoint": 20.6, + "uvIndex": 0, + "visibility": 16586.0, + "windDirection": 224, + "windGust": 11.3, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-08T20:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.96, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.18, + "temperatureApparent": 22.8, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 15051.0, + "windDirection": 221, + "windGust": 10.57, + "windSpeed": 5.13 + }, + { + "forecastStart": "2023-09-08T21:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.41, + "temperatureApparent": 23.07, + "temperatureDewPoint": 20.54, + "uvIndex": 0, + "visibility": 14835.0, + "windDirection": 237, + "windGust": 10.63, + "windSpeed": 5.7 + }, + { + "forecastStart": "2023-09-08T22:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.84, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20790.0, + "windDirection": 258, + "windGust": 10.47, + "windSpeed": 5.22 + }, + { + "forecastStart": "2023-09-08T23:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.98, + "temperatureApparent": 26.11, + "temperatureDewPoint": 21.34, + "uvIndex": 2, + "visibility": 22144.0, + "windDirection": 282, + "windGust": 12.74, + "windSpeed": 5.71 + }, + { + "forecastStart": "2023-09-09T00:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.35, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.13, + "temperatureApparent": 27.42, + "temperatureDewPoint": 21.52, + "uvIndex": 3, + "visibility": 23376.0, + "windDirection": 294, + "windGust": 13.87, + "windSpeed": 6.53 + }, + { + "forecastStart": "2023-09-09T01:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.04, + "temperatureDewPoint": 21.77, + "uvIndex": 5, + "visibility": 23945.0, + "windDirection": 308, + "windGust": 16.04, + "windSpeed": 6.54 + }, + { + "forecastStart": "2023-09-09T02:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.55, + "temperatureApparent": 30.26, + "temperatureDewPoint": 21.96, + "uvIndex": 6, + "visibility": 19031.0, + "windDirection": 314, + "windGust": 18.1, + "windSpeed": 7.32 + }, + { + "forecastStart": "2023-09-09T03:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.86, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.27, + "temperatureApparent": 31.12, + "temperatureDewPoint": 22.09, + "uvIndex": 6, + "visibility": 20583.0, + "windDirection": 317, + "windGust": 20.77, + "windSpeed": 9.1 + }, + { + "forecastStart": "2023-09-09T04:00:00Z", + "cloudCover": 0.69, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.65, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.62, + "temperatureApparent": 31.53, + "temperatureDewPoint": 22.13, + "uvIndex": 6, + "visibility": 20816.0, + "windDirection": 311, + "windGust": 21.27, + "windSpeed": 10.21 + }, + { + "forecastStart": "2023-09-09T05:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.48, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.42, + "temperatureApparent": 31.3, + "temperatureDewPoint": 22.14, + "uvIndex": 5, + "visibility": 25254.0, + "windDirection": 317, + "windGust": 19.62, + "windSpeed": 10.53 + }, + { + "forecastStart": "2023-09-09T06:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.9, + "temperatureApparent": 30.76, + "temperatureDewPoint": 22.2, + "uvIndex": 3, + "visibility": 23283.0, + "windDirection": 335, + "windGust": 18.98, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-09T07:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.76, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.12, + "temperatureApparent": 29.88, + "temperatureDewPoint": 22.17, + "uvIndex": 2, + "visibility": 24299.0, + "windDirection": 338, + "windGust": 17.04, + "windSpeed": 7.75 + }, + { + "forecastStart": "2023-09-09T08:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.38, + "temperatureApparent": 29.06, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 21872.0, + "windDirection": 342, + "windGust": 14.75, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-09T09:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.37, + "temperatureApparent": 27.88, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 19645.0, + "windDirection": 344, + "windGust": 10.43, + "windSpeed": 5.2 + }, + { + "forecastStart": "2023-09-09T10:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.73, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 26.92, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 20088.0, + "windDirection": 339, + "windGust": 6.95, + "windSpeed": 3.59 + }, + { + "forecastStart": "2023-09-09T11:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.07, + "temperatureApparent": 26.39, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 17853.0, + "windDirection": 326, + "windGust": 5.27, + "windSpeed": 2.1 + }, + { + "forecastStart": "2023-09-09T12:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.52, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.87, + "temperatureApparent": 26.15, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 15352.0, + "windDirection": 257, + "windGust": 5.48, + "windSpeed": 0.93 + }, + { + "forecastStart": "2023-09-09T13:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.53, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 16260.0, + "windDirection": 188, + "windGust": 4.44, + "windSpeed": 1.79 + }, + { + "forecastStart": "2023-09-09T14:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.11, + "temperatureApparent": 25.29, + "temperatureDewPoint": 21.67, + "uvIndex": 0, + "visibility": 17443.0, + "windDirection": 183, + "windGust": 4.49, + "windSpeed": 2.19 + }, + { + "forecastStart": "2023-09-09T15:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.62, + "temperatureDewPoint": 21.36, + "uvIndex": 0, + "visibility": 17538.0, + "windDirection": 179, + "windGust": 5.32, + "windSpeed": 2.65 + }, + { + "forecastStart": "2023-09-09T16:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.09, + "temperatureApparent": 23.98, + "temperatureDewPoint": 21.08, + "uvIndex": 0, + "visibility": 18544.0, + "windDirection": 173, + "windGust": 5.81, + "windSpeed": 3.2 + }, + { + "forecastStart": "2023-09-09T17:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.85, + "temperatureApparent": 23.66, + "temperatureDewPoint": 20.91, + "uvIndex": 0, + "visibility": 15814.0, + "windDirection": 159, + "windGust": 5.53, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-09T18:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.62, + "temperatureApparent": 23.34, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 13955.0, + "windDirection": 153, + "windGust": 6.09, + "windSpeed": 3.36 + }, + { + "forecastStart": "2023-09-09T19:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.42, + "temperatureApparent": 23.06, + "temperatureDewPoint": 20.48, + "uvIndex": 0, + "visibility": 13042.0, + "windDirection": 150, + "windGust": 6.83, + "windSpeed": 3.71 + }, + { + "forecastStart": "2023-09-09T20:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.04, + "temperatureApparent": 22.52, + "temperatureDewPoint": 20.04, + "uvIndex": 0, + "visibility": 13016.0, + "windDirection": 156, + "windGust": 7.98, + "windSpeed": 4.27 + }, + { + "forecastStart": "2023-09-09T21:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.25, + "temperatureApparent": 22.78, + "temperatureDewPoint": 20.18, + "uvIndex": 0, + "visibility": 13648.0, + "windDirection": 156, + "windGust": 8.4, + "windSpeed": 4.69 + }, + { + "forecastStart": "2023-09-09T22:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.06, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 20589.0, + "windDirection": 150, + "windGust": 7.66, + "windSpeed": 4.33 + }, + { + "forecastStart": "2023-09-09T23:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.64, + "temperatureApparent": 28.29, + "temperatureDewPoint": 22.26, + "uvIndex": 2, + "visibility": 24505.0, + "windDirection": 123, + "windGust": 9.63, + "windSpeed": 3.91 + }, + { + "forecastStart": "2023-09-10T00:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.42, + "temperatureApparent": 30.44, + "temperatureDewPoint": 22.64, + "uvIndex": 4, + "visibility": 25988.0, + "windDirection": 105, + "windGust": 12.59, + "windSpeed": 3.96 + }, + { + "forecastStart": "2023-09-10T01:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.88, + "temperatureApparent": 32.23, + "temperatureDewPoint": 22.95, + "uvIndex": 5, + "visibility": 26343.0, + "windDirection": 99, + "windGust": 14.17, + "windSpeed": 4.06 + }, + { + "forecastStart": "2023-09-10T02:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.07, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.89, + "temperatureApparent": 33.37, + "temperatureDewPoint": 22.95, + "uvIndex": 6, + "visibility": 20305.0, + "windDirection": 93, + "windGust": 17.75, + "windSpeed": 4.87 + }, + { + "forecastStart": "2023-09-10T03:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1010.78, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.63, + "temperatureApparent": 34.32, + "temperatureDewPoint": 23.15, + "uvIndex": 6, + "visibility": 21524.0, + "windDirection": 78, + "windGust": 17.43, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-10T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.32, + "temperatureApparent": 33.97, + "temperatureDewPoint": 23.16, + "uvIndex": 5, + "visibility": 19608.0, + "windDirection": 60, + "windGust": 15.24, + "windSpeed": 4.9 + }, + { + "forecastStart": "2023-09-10T05:00:00Z", + "cloudCover": 0.79, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.01, + "temperatureApparent": 33.68, + "temperatureDewPoint": 23.26, + "uvIndex": 4, + "visibility": 19170.0, + "windDirection": 80, + "windGust": 13.53, + "windSpeed": 5.98 + }, + { + "forecastStart": "2023-09-10T06:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 1.0, + "precipitationIntensity": 1.0, + "precipitationChance": 0.17, + "precipitationType": "rain", + "pressure": 1010.0, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.51, + "temperatureApparent": 33.17, + "temperatureDewPoint": 23.37, + "uvIndex": 3, + "visibility": 20385.0, + "windDirection": 83, + "windGust": 12.55, + "windSpeed": 6.84 + }, + { + "forecastStart": "2023-09-10T07:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1010.27, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.28, + "temperatureDewPoint": 23.36, + "uvIndex": 2, + "visibility": 21033.0, + "windDirection": 90, + "windGust": 10.16, + "windSpeed": 6.07 + }, + { + "forecastStart": "2023-09-10T08:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.6, + "temperatureApparent": 30.9, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 19490.0, + "windDirection": 101, + "windGust": 8.18, + "windSpeed": 4.82 + }, + { + "forecastStart": "2023-09-10T09:00:00Z", + "cloudCover": 0.93, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.9, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.52, + "temperatureApparent": 29.7, + "temperatureDewPoint": 23.2, + "uvIndex": 0, + "visibility": 15809.0, + "windDirection": 128, + "windGust": 8.89, + "windSpeed": 4.95 + }, + { + "forecastStart": "2023-09-10T10:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.61, + "temperatureApparent": 28.6, + "temperatureDewPoint": 23.02, + "uvIndex": 0, + "visibility": 16975.0, + "windDirection": 134, + "windGust": 10.03, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-10T11:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.06, + "temperatureApparent": 27.88, + "temperatureDewPoint": 22.78, + "uvIndex": 0, + "visibility": 17463.0, + "windDirection": 137, + "windGust": 12.4, + "windSpeed": 5.41 + }, + { + "forecastStart": "2023-09-10T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.58, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.78, + "temperatureApparent": 27.45, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 18599.0, + "windDirection": 143, + "windGust": 16.36, + "windSpeed": 6.31 + }, + { + "forecastStart": "2023-09-10T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.52, + "temperatureApparent": 27.12, + "temperatureDewPoint": 22.4, + "uvIndex": 0, + "visibility": 19560.0, + "windDirection": 144, + "windGust": 19.66, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-10T14:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.4, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.29, + "temperatureApparent": 26.81, + "temperatureDewPoint": 22.25, + "uvIndex": 0, + "visibility": 20164.0, + "windDirection": 141, + "windGust": 21.15, + "windSpeed": 7.46 + }, + { + "forecastStart": "2023-09-10T15:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.95, + "temperatureApparent": 26.33, + "temperatureDewPoint": 21.99, + "uvIndex": 0, + "visibility": 20723.0, + "windDirection": 141, + "windGust": 22.26, + "windSpeed": 7.84 + }, + { + "forecastStart": "2023-09-10T16:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.77, + "temperatureApparent": 26.06, + "temperatureDewPoint": 21.81, + "uvIndex": 0, + "visibility": 20584.0, + "windDirection": 144, + "windGust": 23.53, + "windSpeed": 8.63 + }, + { + "forecastStart": "2023-09-10T17:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.47, + "temperatureApparent": 25.65, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 21559.0, + "windDirection": 144, + "windGust": 22.83, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-10T18:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.69, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.28, + "temperatureApparent": 25.4, + "temperatureDewPoint": 21.47, + "uvIndex": 0, + "visibility": 20210.0, + "windDirection": 143, + "windGust": 23.7, + "windSpeed": 8.7 + }, + { + "forecastStart": "2023-09-10T19:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.23, + "temperatureDewPoint": 21.41, + "uvIndex": 0, + "visibility": 20532.0, + "windDirection": 140, + "windGust": 24.24, + "windSpeed": 8.74 + }, + { + "forecastStart": "2023-09-10T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.33, + "temperatureApparent": 25.5, + "temperatureDewPoint": 21.6, + "uvIndex": 0, + "visibility": 21210.0, + "windDirection": 138, + "windGust": 23.99, + "windSpeed": 8.81 + }, + { + "forecastStart": "2023-09-10T21:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.1, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.67, + "temperatureApparent": 25.86, + "temperatureDewPoint": 21.56, + "uvIndex": 0, + "visibility": 22103.0, + "windDirection": 138, + "windGust": 25.55, + "windSpeed": 9.05 + }, + { + "forecastStart": "2023-09-10T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 26.97, + "temperatureDewPoint": 21.8, + "uvIndex": 1, + "visibility": 22607.0, + "windDirection": 140, + "windGust": 29.08, + "windSpeed": 10.37 + }, + { + "forecastStart": "2023-09-10T23:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.36, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.85, + "temperatureApparent": 28.36, + "temperatureDewPoint": 21.89, + "uvIndex": 2, + "visibility": 23231.0, + "windDirection": 140, + "windGust": 34.13, + "windSpeed": 12.56 + }, + { + "forecastStart": "2023-09-11T00:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.25, + "temperatureApparent": 30.09, + "temperatureDewPoint": 22.3, + "uvIndex": 3, + "visibility": 24284.0, + "windDirection": 140, + "windGust": 38.2, + "windSpeed": 15.65 + }, + { + "forecastStart": "2023-09-11T01:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.39, + "temperatureApparent": 31.35, + "temperatureDewPoint": 22.3, + "uvIndex": 5, + "visibility": 24490.0, + "windDirection": 141, + "windGust": 37.55, + "windSpeed": 15.78 + }, + { + "forecastStart": "2023-09-11T02:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.55, + "temperatureApparent": 32.71, + "temperatureDewPoint": 22.43, + "uvIndex": 6, + "visibility": 23811.0, + "windDirection": 143, + "windGust": 35.86, + "windSpeed": 15.41 + }, + { + "forecastStart": "2023-09-11T03:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.61, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.27, + "temperatureApparent": 33.55, + "temperatureDewPoint": 22.5, + "uvIndex": 6, + "visibility": 20414.0, + "windDirection": 141, + "windGust": 35.88, + "windSpeed": 15.51 + }, + { + "forecastStart": "2023-09-11T04:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.36, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.43, + "temperatureApparent": 33.81, + "temperatureDewPoint": 22.65, + "uvIndex": 5, + "visibility": 19760.0, + "windDirection": 140, + "windGust": 35.99, + "windSpeed": 15.75 + }, + { + "forecastStart": "2023-09-11T05:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.47, + "temperatureDewPoint": 22.59, + "uvIndex": 4, + "visibility": 24662.0, + "windDirection": 137, + "windGust": 33.61, + "windSpeed": 15.36 + }, + { + "forecastStart": "2023-09-11T06:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1009.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.97, + "temperatureApparent": 33.23, + "temperatureDewPoint": 22.52, + "uvIndex": 3, + "visibility": 26577.0, + "windDirection": 138, + "windGust": 32.61, + "windSpeed": 14.98 + }, + { + "forecastStart": "2023-09-11T07:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.25, + "temperatureApparent": 32.28, + "temperatureDewPoint": 22.24, + "uvIndex": 2, + "visibility": 24239.0, + "windDirection": 138, + "windGust": 28.1, + "windSpeed": 13.88 + }, + { + "forecastStart": "2023-09-11T08:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.48, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.32, + "temperatureApparent": 31.19, + "temperatureDewPoint": 22.14, + "uvIndex": 0, + "visibility": 25056.0, + "windDirection": 137, + "windGust": 24.22, + "windSpeed": 13.02 + }, + { + "forecastStart": "2023-09-11T09:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.81, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.15, + "temperatureApparent": 29.77, + "temperatureDewPoint": 21.85, + "uvIndex": 0, + "visibility": 23658.0, + "windDirection": 138, + "windGust": 22.5, + "windSpeed": 11.94 + }, + { + "forecastStart": "2023-09-11T10:00:00Z", + "cloudCover": 0.63, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 28.77, + "temperatureDewPoint": 21.72, + "uvIndex": 0, + "visibility": 23317.0, + "windDirection": 137, + "windGust": 21.47, + "windSpeed": 11.25 + }, + { + "forecastStart": "2023-09-11T11:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.8, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.62, + "temperatureApparent": 28.09, + "temperatureDewPoint": 21.83, + "uvIndex": 0, + "visibility": 21978.0, + "windDirection": 141, + "windGust": 22.71, + "windSpeed": 12.39 + }, + { + "forecastStart": "2023-09-11T12:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.16, + "temperatureApparent": 27.57, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 20260.0, + "windDirection": 143, + "windGust": 23.67, + "windSpeed": 12.83 + }, + { + "forecastStart": "2023-09-11T13:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.97, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.74, + "temperatureApparent": 27.07, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 18240.0, + "windDirection": 146, + "windGust": 23.34, + "windSpeed": 12.62 + }, + { + "forecastStart": "2023-09-11T14:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.83, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.41, + "temperatureApparent": 26.71, + "temperatureDewPoint": 21.68, + "uvIndex": 0, + "visibility": 18444.0, + "windDirection": 147, + "windGust": 22.9, + "windSpeed": 12.07 + }, + { + "forecastStart": "2023-09-11T15:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.06, + "temperatureApparent": 26.31, + "temperatureDewPoint": 21.65, + "uvIndex": 0, + "visibility": 20008.0, + "windDirection": 147, + "windGust": 22.01, + "windSpeed": 11.19 + }, + { + "forecastStart": "2023-09-11T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.56, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.73, + "temperatureApparent": 25.92, + "temperatureDewPoint": 21.55, + "uvIndex": 0, + "visibility": 19191.0, + "windDirection": 149, + "windGust": 21.29, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-11T17:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.64, + "temperatureApparent": 25.79, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 19549.0, + "windDirection": 150, + "windGust": 20.52, + "windSpeed": 10.5 + }, + { + "forecastStart": "2023-09-11T18:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.3, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.54, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 19709.0, + "windDirection": 149, + "windGust": 20.04, + "windSpeed": 10.51 + }, + { + "forecastStart": "2023-09-11T19:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.42, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 17439.0, + "windDirection": 146, + "windGust": 18.07, + "windSpeed": 10.13 + }, + { + "forecastStart": "2023-09-11T20:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.13, + "precipitationType": "rain", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.15, + "temperatureApparent": 25.16, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 15297.0, + "windDirection": 141, + "windGust": 16.86, + "windSpeed": 10.34 + }, + { + "forecastStart": "2023-09-11T21:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.71, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.43, + "temperatureApparent": 25.54, + "temperatureDewPoint": 21.4, + "uvIndex": 0, + "visibility": 17935.0, + "windDirection": 138, + "windGust": 16.66, + "windSpeed": 10.68 + }, + { + "forecastStart": "2023-09-11T22:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.45, + "temperatureApparent": 26.83, + "temperatureDewPoint": 21.88, + "uvIndex": 1, + "visibility": 17153.0, + "windDirection": 137, + "windGust": 17.21, + "windSpeed": 10.61 + }, + { + "forecastStart": "2023-09-11T23:00:00Z", + "cloudCover": 0.78, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.55, + "temperatureApparent": 28.22, + "temperatureDewPoint": 22.33, + "uvIndex": 2, + "visibility": 19126.0, + "windDirection": 138, + "windGust": 19.23, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T00:00:00Z", + "cloudCover": 0.79, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.61, + "temperatureApparent": 29.53, + "temperatureDewPoint": 22.63, + "uvIndex": 3, + "visibility": 16639.0, + "windDirection": 140, + "windGust": 20.61, + "windSpeed": 11.13 + }, + { + "forecastStart": "2023-09-12T01:00:00Z", + "cloudCover": 0.82, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.75, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.16, + "precipitationType": "rain", + "pressure": 1011.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 31.24, + "temperatureDewPoint": 23.12, + "uvIndex": 4, + "visibility": 16716.0, + "windDirection": 141, + "windGust": 23.35, + "windSpeed": 11.98 + }, + { + "forecastStart": "2023-09-12T02:00:00Z", + "cloudCover": 0.85, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.97, + "temperatureApparent": 32.63, + "temperatureDewPoint": 23.5, + "uvIndex": 5, + "visibility": 19639.0, + "windDirection": 143, + "windGust": 26.45, + "windSpeed": 13.01 + }, + { + "forecastStart": "2023-09-12T03:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.76, + "temperatureApparent": 33.53, + "temperatureDewPoint": 23.51, + "uvIndex": 5, + "visibility": 23538.0, + "windDirection": 141, + "windGust": 28.95, + "windSpeed": 13.9 + }, + { + "forecastStart": "2023-09-12T04:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.79, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.21, + "temperatureApparent": 34.01, + "temperatureDewPoint": 23.45, + "uvIndex": 5, + "visibility": 24964.0, + "windDirection": 141, + "windGust": 27.9, + "windSpeed": 13.95 + }, + { + "forecastStart": "2023-09-12T05:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.42, + "temperatureApparent": 34.02, + "temperatureDewPoint": 23.05, + "uvIndex": 4, + "visibility": 26399.0, + "windDirection": 140, + "windGust": 26.53, + "windSpeed": 13.78 + }, + { + "forecastStart": "2023-09-12T06:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.21, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.07, + "temperatureApparent": 33.39, + "temperatureDewPoint": 22.62, + "uvIndex": 3, + "visibility": 27308.0, + "windDirection": 138, + "windGust": 24.56, + "windSpeed": 13.74 + }, + { + "forecastStart": "2023-09-12T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.06, + "temperatureApparent": 31.98, + "temperatureDewPoint": 22.06, + "uvIndex": 2, + "visibility": 27514.0, + "windDirection": 138, + "windGust": 22.78, + "windSpeed": 13.21 + }, + { + "forecastStart": "2023-09-12T08:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.14, + "temperatureApparent": 30.87, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 27191.0, + "windDirection": 140, + "windGust": 19.92, + "windSpeed": 12.0 + }, + { + "forecastStart": "2023-09-12T09:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.18, + "temperatureApparent": 29.73, + "temperatureDewPoint": 21.69, + "uvIndex": 0, + "visibility": 26334.0, + "windDirection": 141, + "windGust": 17.65, + "windSpeed": 10.97 + }, + { + "forecastStart": "2023-09-12T10:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.75, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.23, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.19, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.45, + "uvIndex": 0, + "visibility": 24588.0, + "windDirection": 143, + "windGust": 15.87, + "windSpeed": 10.23 + }, + { + "forecastStart": "2023-09-12T11:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1011.79, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.36, + "temperatureApparent": 27.6, + "temperatureDewPoint": 21.33, + "uvIndex": 0, + "visibility": 22303.0, + "windDirection": 146, + "windGust": 13.9, + "windSpeed": 9.39 + }, + { + "forecastStart": "2023-09-12T12:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.47, + "precipitationType": "clear", + "pressure": 1012.12, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.68, + "temperatureApparent": 26.82, + "temperatureDewPoint": 21.24, + "uvIndex": 0, + "visibility": 20535.0, + "windDirection": 147, + "windGust": 13.32, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-12T13:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1012.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.23, + "temperatureApparent": 26.32, + "temperatureDewPoint": 21.2, + "uvIndex": 0, + "visibility": 19800.0, + "windDirection": 149, + "windGust": 13.18, + "windSpeed": 8.59 + }, + { + "forecastStart": "2023-09-12T14:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.0, + "temperatureDewPoint": 21.27, + "uvIndex": 0, + "visibility": 19587.0, + "windDirection": 149, + "windGust": 13.84, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-12T15:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.61, + "temperatureApparent": 25.68, + "temperatureDewPoint": 21.28, + "uvIndex": 0, + "visibility": 19418.0, + "windDirection": 149, + "windGust": 15.08, + "windSpeed": 8.93 + }, + { + "forecastStart": "2023-09-12T16:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.18, + "temperatureApparent": 25.12, + "temperatureDewPoint": 21.01, + "uvIndex": 0, + "visibility": 19187.0, + "windDirection": 146, + "windGust": 16.74, + "windSpeed": 9.49 + }, + { + "forecastStart": "2023-09-12T17:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.86, + "temperatureApparent": 24.72, + "temperatureDewPoint": 20.84, + "uvIndex": 0, + "visibility": 19001.0, + "windDirection": 146, + "windGust": 17.45, + "windSpeed": 9.12 + }, + { + "forecastStart": "2023-09-12T18:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.77, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.41, + "temperatureDewPoint": 20.68, + "uvIndex": 0, + "visibility": 18698.0, + "windDirection": 149, + "windGust": 17.04, + "windSpeed": 8.68 + }, + { + "forecastStart": "2023-09-12T19:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.93, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.37, + "temperatureApparent": 24.1, + "temperatureDewPoint": 20.58, + "uvIndex": 0, + "visibility": 17831.0, + "windDirection": 149, + "windGust": 16.8, + "windSpeed": 8.61 + }, + { + "forecastStart": "2023-09-12T20:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.15, + "temperatureApparent": 23.85, + "temperatureDewPoint": 20.5, + "uvIndex": 0, + "visibility": 16846.0, + "windDirection": 150, + "windGust": 15.35, + "windSpeed": 8.36 + }, + { + "forecastStart": "2023-09-12T21:00:00Z", + "cloudCover": 0.75, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.49, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.59, + "temperatureApparent": 24.36, + "temperatureDewPoint": 20.65, + "uvIndex": 0, + "visibility": 16919.0, + "windDirection": 155, + "windGust": 14.09, + "windSpeed": 7.77 + }, + { + "forecastStart": "2023-09-12T22:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.72, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.82, + "temperatureApparent": 25.82, + "temperatureDewPoint": 21.03, + "uvIndex": 1, + "visibility": 19326.0, + "windDirection": 152, + "windGust": 14.04, + "windSpeed": 7.25 + }, + { + "forecastStart": "2023-09-12T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.5, + "temperatureApparent": 27.77, + "temperatureDewPoint": 21.38, + "uvIndex": 2, + "visibility": 22800.0, + "windDirection": 149, + "windGust": 15.31, + "windSpeed": 7.14 + }, + { + "forecastStart": "2023-09-13T00:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.13, + "temperatureApparent": 29.74, + "temperatureDewPoint": 21.83, + "uvIndex": 4, + "visibility": 24706.0, + "windDirection": 141, + "windGust": 16.42, + "windSpeed": 6.89 + }, + { + "forecastStart": "2023-09-13T01:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.65, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.44, + "temperatureApparent": 31.24, + "temperatureDewPoint": 21.96, + "uvIndex": 5, + "visibility": 23309.0, + "windDirection": 137, + "windGust": 18.64, + "windSpeed": 6.65 + }, + { + "forecastStart": "2023-09-13T02:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.26, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.41, + "temperatureApparent": 32.28, + "temperatureDewPoint": 21.89, + "uvIndex": 5, + "visibility": 20329.0, + "windDirection": 128, + "windGust": 21.69, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-13T03:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.88, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.06, + "temperatureApparent": 33.0, + "temperatureDewPoint": 21.88, + "uvIndex": 6, + "visibility": 17382.0, + "windDirection": 111, + "windGust": 23.41, + "windSpeed": 7.33 + }, + { + "forecastStart": "2023-09-13T04:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 0.9, + "precipitationIntensity": 0.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.4, + "temperatureApparent": 33.43, + "temperatureDewPoint": 21.98, + "uvIndex": 5, + "visibility": 18579.0, + "windDirection": 56, + "windGust": 23.1, + "windSpeed": 8.09 + }, + { + "forecastStart": "2023-09-13T05:00:00Z", + "cloudCover": 0.72, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.61, + "precipitationAmount": 1.9, + "precipitationIntensity": 1.9, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.2, + "temperatureApparent": 33.16, + "temperatureDewPoint": 21.9, + "uvIndex": 4, + "visibility": 18850.0, + "windDirection": 20, + "windGust": 21.81, + "windSpeed": 9.46 + }, + { + "forecastStart": "2023-09-13T06:00:00Z", + "cloudCover": 0.74, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 2.3, + "precipitationIntensity": 2.3, + "precipitationChance": 0.11, + "precipitationType": "rain", + "pressure": 1011.17, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.67, + "temperatureApparent": 32.59, + "temperatureDewPoint": 21.93, + "uvIndex": 3, + "visibility": 20634.0, + "windDirection": 20, + "windGust": 19.72, + "windSpeed": 9.8 + }, + { + "forecastStart": "2023-09-13T07:00:00Z", + "cloudCover": 0.69, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 1.8, + "precipitationIntensity": 1.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.77, + "temperatureApparent": 31.81, + "temperatureDewPoint": 22.37, + "uvIndex": 1, + "visibility": 19468.0, + "windDirection": 18, + "windGust": 17.55, + "windSpeed": 9.23 + }, + { + "forecastStart": "2023-09-13T08:00:00Z", + "cloudCover": 0.73, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.8, + "precipitationIntensity": 0.8, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1011.6, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.61, + "temperatureApparent": 30.78, + "temperatureDewPoint": 22.91, + "uvIndex": 0, + "visibility": 18451.0, + "windDirection": 27, + "windGust": 15.08, + "windSpeed": 8.05 + }, + { + "forecastStart": "2023-09-13T09:00:00Z", + "cloudCover": 0.76, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.94, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.33, + "temperatureApparent": 29.4, + "temperatureDewPoint": 23.01, + "uvIndex": 0, + "visibility": 19184.0, + "windDirection": 32, + "windGust": 12.17, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-13T10:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.3, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.54, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 17878.0, + "windDirection": 69, + "windGust": 11.64, + "windSpeed": 6.69 + }, + { + "forecastStart": "2023-09-13T11:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.98, + "temperatureApparent": 27.73, + "temperatureDewPoint": 22.63, + "uvIndex": 0, + "visibility": 19357.0, + "windDirection": 155, + "windGust": 11.91, + "windSpeed": 6.23 + }, + { + "forecastStart": "2023-09-13T12:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.53, + "temperatureApparent": 27.11, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 19658.0, + "windDirection": 161, + "windGust": 12.47, + "windSpeed": 5.73 + }, + { + "forecastStart": "2023-09-13T13:00:00Z", + "cloudCover": 0.82, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.03, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.28, + "uvIndex": 0, + "visibility": 20272.0, + "windDirection": 161, + "windGust": 13.57, + "windSpeed": 5.66 + }, + { + "forecastStart": "2023-09-13T14:00:00Z", + "cloudCover": 0.84, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.91, + "temperatureApparent": 26.36, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 20994.0, + "windDirection": 159, + "windGust": 15.07, + "windSpeed": 5.83 + }, + { + "forecastStart": "2023-09-13T15:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.12, + "temperatureDewPoint": 22.17, + "uvIndex": 0, + "visibility": 21105.0, + "windDirection": 158, + "windGust": 16.06, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-13T16:00:00Z", + "cloudCover": 0.88, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.9, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.35, + "temperatureApparent": 25.67, + "temperatureDewPoint": 21.98, + "uvIndex": 0, + "visibility": 20061.0, + "windDirection": 153, + "windGust": 16.05, + "windSpeed": 5.75 + }, + { + "forecastStart": "2023-09-13T17:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.85, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.14, + "temperatureApparent": 25.39, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 18402.0, + "windDirection": 150, + "windGust": 15.52, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-13T18:00:00Z", + "cloudCover": 0.92, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.99, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.76, + "uvIndex": 0, + "visibility": 17039.0, + "windDirection": 149, + "windGust": 15.01, + "windSpeed": 5.32 + }, + { + "forecastStart": "2023-09-13T19:00:00Z", + "cloudCover": 0.9, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.79, + "temperatureApparent": 24.96, + "temperatureDewPoint": 21.7, + "uvIndex": 0, + "visibility": 16081.0, + "windDirection": 147, + "windGust": 14.39, + "windSpeed": 5.33 + }, + { + "forecastStart": "2023-09-13T20:00:00Z", + "cloudCover": 0.89, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.63, + "temperatureApparent": 24.75, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 15426.0, + "windDirection": 147, + "windGust": 13.79, + "windSpeed": 5.43 + }, + { + "forecastStart": "2023-09-13T21:00:00Z", + "cloudCover": 0.86, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.33, + "temperatureDewPoint": 21.8, + "uvIndex": 0, + "visibility": 15660.0, + "windDirection": 147, + "windGust": 14.12, + "windSpeed": 5.52 + }, + { + "forecastStart": "2023-09-13T22:00:00Z", + "cloudCover": 0.77, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.59, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.26, + "temperatureApparent": 26.73, + "temperatureDewPoint": 22.14, + "uvIndex": 1, + "visibility": 17559.0, + "windDirection": 147, + "windGust": 16.14, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-13T23:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.67, + "temperatureApparent": 28.37, + "temperatureDewPoint": 22.37, + "uvIndex": 2, + "visibility": 20352.0, + "windDirection": 146, + "windGust": 19.09, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T00:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.78, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.37, + "temperatureApparent": 30.48, + "temperatureDewPoint": 22.85, + "uvIndex": 4, + "visibility": 22307.0, + "windDirection": 143, + "windGust": 21.6, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-14T01:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.61, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.73, + "temperatureApparent": 32.18, + "temperatureDewPoint": 23.18, + "uvIndex": 5, + "visibility": 22630.0, + "windDirection": 138, + "windGust": 23.36, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T02:00:00Z", + "cloudCover": 0.54, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.32, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.87, + "temperatureApparent": 33.5, + "temperatureDewPoint": 23.23, + "uvIndex": 6, + "visibility": 22159.0, + "windDirection": 111, + "windGust": 24.72, + "windSpeed": 4.99 + }, + { + "forecastStart": "2023-09-14T03:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.04, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.42, + "temperatureDewPoint": 23.28, + "uvIndex": 6, + "visibility": 21610.0, + "windDirection": 354, + "windGust": 25.23, + "windSpeed": 4.74 + }, + { + "forecastStart": "2023-09-14T04:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.77, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.98, + "temperatureApparent": 34.85, + "temperatureDewPoint": 23.37, + "uvIndex": 6, + "visibility": 21210.0, + "windDirection": 341, + "windGust": 24.6, + "windSpeed": 4.79 + }, + { + "forecastStart": "2023-09-14T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1012.53, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.73, + "temperatureApparent": 34.48, + "temperatureDewPoint": 23.24, + "uvIndex": 5, + "visibility": 20870.0, + "windDirection": 336, + "windGust": 23.28, + "windSpeed": 5.07 + }, + { + "forecastStart": "2023-09-14T06:00:00Z", + "cloudCover": 0.59, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1012.49, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.23, + "temperatureApparent": 33.82, + "temperatureDewPoint": 23.07, + "uvIndex": 3, + "visibility": 20831.0, + "windDirection": 336, + "windGust": 22.05, + "windSpeed": 5.34 + }, + { + "forecastStart": "2023-09-14T07:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.4, + "precipitationType": "rain", + "pressure": 1012.73, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.47, + "temperatureApparent": 32.94, + "temperatureDewPoint": 23.04, + "uvIndex": 2, + "visibility": 21284.0, + "windDirection": 339, + "windGust": 21.18, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-14T08:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.45, + "precipitationType": "clear", + "pressure": 1013.16, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.35, + "temperatureApparent": 31.56, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 21999.0, + "windDirection": 342, + "windGust": 20.35, + "windSpeed": 5.93 + }, + { + "forecastStart": "2023-09-14T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1013.62, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.11, + "temperatureApparent": 30.03, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 22578.0, + "windDirection": 347, + "windGust": 19.42, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-14T10:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.27, + "temperatureApparent": 29.04, + "temperatureDewPoint": 22.38, + "uvIndex": 0, + "visibility": 22916.0, + "windDirection": 348, + "windGust": 18.19, + "windSpeed": 5.31 + }, + { + "forecastStart": "2023-09-14T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.56, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.53, + "temperatureApparent": 28.23, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 23051.0, + "windDirection": 177, + "windGust": 16.79, + "windSpeed": 4.28 + }, + { + "forecastStart": "2023-09-14T12:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.87, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.9, + "temperatureApparent": 27.51, + "temperatureDewPoint": 22.32, + "uvIndex": 0, + "visibility": 22814.0, + "windDirection": 171, + "windGust": 15.61, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-14T13:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.17, + "temperatureApparent": 26.6, + "temperatureDewPoint": 22.06, + "uvIndex": 0, + "visibility": 21946.0, + "windDirection": 171, + "windGust": 14.7, + "windSpeed": 4.11 + }, + { + "forecastStart": "2023-09-14T14:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.8, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.9, + "temperatureDewPoint": 21.86, + "uvIndex": 0, + "visibility": 20560.0, + "windDirection": 171, + "windGust": 13.81, + "windSpeed": 4.97 + }, + { + "forecastStart": "2023-09-14T15:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.66, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.1, + "temperatureApparent": 25.28, + "temperatureDewPoint": 21.66, + "uvIndex": 0, + "visibility": 19040.0, + "windDirection": 170, + "windGust": 12.88, + "windSpeed": 5.57 + }, + { + "forecastStart": "2023-09-14T16:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.69, + "temperatureApparent": 24.76, + "temperatureDewPoint": 21.46, + "uvIndex": 0, + "visibility": 17747.0, + "windDirection": 168, + "windGust": 12.0, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T17:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.4, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.4, + "temperatureApparent": 24.4, + "temperatureDewPoint": 21.32, + "uvIndex": 0, + "visibility": 16872.0, + "windDirection": 165, + "windGust": 11.43, + "windSpeed": 5.48 + }, + { + "forecastStart": "2023-09-14T18:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.44, + "precipitationType": "clear", + "pressure": 1014.45, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.58, + "temperatureApparent": 24.63, + "temperatureDewPoint": 21.43, + "uvIndex": 0, + "visibility": 16548.0, + "windDirection": 162, + "windGust": 11.42, + "windSpeed": 5.38 + }, + { + "forecastStart": "2023-09-14T19:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.52, + "precipitationType": "clear", + "pressure": 1014.63, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.88, + "temperatureApparent": 25.01, + "temperatureDewPoint": 21.58, + "uvIndex": 0, + "visibility": 16862.0, + "windDirection": 161, + "windGust": 12.15, + "windSpeed": 5.39 + }, + { + "forecastStart": "2023-09-14T20:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.51, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.36, + "temperatureApparent": 25.6, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 17845.0, + "windDirection": 159, + "windGust": 13.54, + "windSpeed": 5.45 + }, + { + "forecastStart": "2023-09-14T21:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.42, + "precipitationType": "clear", + "pressure": 1015.18, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.2, + "temperatureApparent": 26.61, + "temperatureDewPoint": 22.01, + "uvIndex": 0, + "visibility": 19537.0, + "windDirection": 158, + "windGust": 15.48, + "windSpeed": 5.62 + }, + { + "forecastStart": "2023-09-14T22:00:00Z", + "cloudCover": 0.32, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.29, + "precipitationType": "clear", + "pressure": 1015.4, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.68, + "temperatureApparent": 28.46, + "temperatureDewPoint": 22.54, + "uvIndex": 1, + "visibility": 21828.0, + "windDirection": 158, + "windGust": 17.86, + "windSpeed": 5.84 + }, + { + "forecastStart": "2023-09-14T23:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.77, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.54, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 30.28, + "temperatureDewPoint": 22.85, + "uvIndex": 2, + "visibility": 24036.0, + "windDirection": 155, + "windGust": 20.19, + "windSpeed": 6.09 + }, + { + "forecastStart": "2023-09-15T00:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.55, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.65, + "temperatureApparent": 32.15, + "temperatureDewPoint": 23.29, + "uvIndex": 4, + "visibility": 25340.0, + "windDirection": 152, + "windGust": 21.83, + "windSpeed": 6.42 + }, + { + "forecastStart": "2023-09-15T01:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.35, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.65, + "temperatureApparent": 33.4, + "temperatureDewPoint": 23.5, + "uvIndex": 6, + "visibility": 25384.0, + "windDirection": 144, + "windGust": 22.56, + "windSpeed": 6.91 + }, + { + "forecastStart": "2023-09-15T02:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.0, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.38, + "temperatureApparent": 34.24, + "temperatureDewPoint": 23.52, + "uvIndex": 7, + "visibility": 24635.0, + "windDirection": 336, + "windGust": 22.83, + "windSpeed": 7.47 + }, + { + "forecastStart": "2023-09-15T03:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.62, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.93, + "temperatureApparent": 34.88, + "temperatureDewPoint": 23.53, + "uvIndex": 7, + "visibility": 23513.0, + "windDirection": 336, + "windGust": 22.98, + "windSpeed": 7.95 + }, + { + "forecastStart": "2023-09-15T04:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.31, + "temperatureApparent": 35.35, + "temperatureDewPoint": 23.58, + "uvIndex": 6, + "visibility": 22350.0, + "windDirection": 341, + "windGust": 23.21, + "windSpeed": 8.44 + }, + { + "forecastStart": "2023-09-15T05:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.95, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.46, + "temperatureApparent": 35.61, + "temperatureDewPoint": 23.72, + "uvIndex": 5, + "visibility": 21383.0, + "windDirection": 344, + "windGust": 23.46, + "windSpeed": 8.95 + }, + { + "forecastStart": "2023-09-15T06:00:00Z", + "cloudCover": 0.42, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.83, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.09, + "temperatureApparent": 35.1, + "temperatureDewPoint": 23.58, + "uvIndex": 3, + "visibility": 20900.0, + "windDirection": 347, + "windGust": 23.64, + "windSpeed": 9.13 + }, + { + "forecastStart": "2023-09-15T07:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.66, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.96, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.33, + "temperatureApparent": 34.1, + "temperatureDewPoint": 23.37, + "uvIndex": 2, + "visibility": 21046.0, + "windDirection": 350, + "windGust": 23.66, + "windSpeed": 8.78 + }, + { + "forecastStart": "2023-09-15T08:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.25, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.98, + "temperatureApparent": 32.39, + "temperatureDewPoint": 23.05, + "uvIndex": 0, + "visibility": 21562.0, + "windDirection": 356, + "windGust": 23.51, + "windSpeed": 8.13 + }, + { + "forecastStart": "2023-09-15T09:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.61, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.94, + "temperatureApparent": 31.13, + "temperatureDewPoint": 22.87, + "uvIndex": 0, + "visibility": 22131.0, + "windDirection": 3, + "windGust": 23.21, + "windSpeed": 7.48 + }, + { + "forecastStart": "2023-09-15T10:00:00Z", + "cloudCover": 0.43, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.02, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.95, + "temperatureApparent": 29.98, + "temperatureDewPoint": 22.79, + "uvIndex": 0, + "visibility": 22382.0, + "windDirection": 20, + "windGust": 22.68, + "windSpeed": 6.83 + }, + { + "forecastStart": "2023-09-15T11:00:00Z", + "cloudCover": 0.46, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.43, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.21, + "temperatureApparent": 29.17, + "temperatureDewPoint": 22.81, + "uvIndex": 0, + "visibility": 22366.0, + "windDirection": 129, + "windGust": 22.04, + "windSpeed": 6.1 + }, + { + "forecastStart": "2023-09-15T12:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.71, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 28.42, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 22383.0, + "windDirection": 159, + "windGust": 21.64, + "windSpeed": 5.6 + }, + { + "forecastStart": "2023-09-15T13:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.22, + "temperatureApparent": 28.24, + "temperatureDewPoint": 23.16, + "uvIndex": 0, + "visibility": 21966.0, + "windDirection": 164, + "windGust": 16.35, + "windSpeed": 5.58 + }, + { + "forecastStart": "2023-09-15T14:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.61, + "temperatureApparent": 27.42, + "temperatureDewPoint": 22.86, + "uvIndex": 0, + "visibility": 22357.0, + "windDirection": 168, + "windGust": 17.11, + "windSpeed": 5.79 + }, + { + "forecastStart": "2023-09-15T15:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.21, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.16, + "temperatureApparent": 26.86, + "temperatureDewPoint": 22.71, + "uvIndex": 0, + "visibility": 22189.0, + "windDirection": 182, + "windGust": 17.32, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.78, + "temperatureApparent": 26.4, + "temperatureDewPoint": 22.61, + "uvIndex": 0, + "visibility": 21374.0, + "windDirection": 201, + "windGust": 16.6, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-15T17:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.95, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.46, + "uvIndex": 0, + "visibility": 20612.0, + "windDirection": 219, + "windGust": 15.52, + "windSpeed": 4.62 + }, + { + "forecastStart": "2023-09-15T18:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.88, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.29, + "temperatureApparent": 25.72, + "temperatureDewPoint": 22.27, + "uvIndex": 0, + "visibility": 20500.0, + "windDirection": 216, + "windGust": 14.64, + "windSpeed": 4.32 + }, + { + "forecastStart": "2023-09-15T19:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.91, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.48, + "temperatureApparent": 25.98, + "temperatureDewPoint": 22.39, + "uvIndex": 0, + "visibility": 21319.0, + "windDirection": 198, + "windGust": 14.06, + "windSpeed": 4.73 + }, + { + "forecastStart": "2023-09-15T20:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.99, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 26.34, + "temperatureDewPoint": 22.42, + "uvIndex": 0, + "visibility": 22776.0, + "windDirection": 189, + "windGust": 13.7, + "windSpeed": 5.49 + }, + { + "forecastStart": "2023-09-15T21:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.43, + "temperatureApparent": 27.08, + "temperatureDewPoint": 22.53, + "uvIndex": 0, + "visibility": 24606.0, + "windDirection": 183, + "windGust": 13.77, + "windSpeed": 5.95 + }, + { + "forecastStart": "2023-09-15T22:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.84, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.12, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.47, + "temperatureApparent": 28.28, + "temperatureDewPoint": 22.65, + "uvIndex": 1, + "visibility": 26540.0, + "windDirection": 179, + "windGust": 14.38, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-15T23:00:00Z", + "cloudCover": 0.52, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.13, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.85, + "temperatureApparent": 29.91, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 28300.0, + "windDirection": 170, + "windGust": 15.2, + "windSpeed": 5.27 + }, + { + "forecastStart": "2023-09-16T00:00:00Z", + "cloudCover": 0.44, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.74, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1015.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.02, + "temperatureApparent": 31.22, + "temperatureDewPoint": 22.86, + "uvIndex": 4, + "visibility": 29608.0, + "windDirection": 155, + "windGust": 15.85, + "windSpeed": 4.76 + }, + { + "forecastStart": "2023-09-16T01:00:00Z", + "cloudCover": 0.24, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.52, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.24, + "temperatureApparent": 32.46, + "temperatureDewPoint": 22.63, + "uvIndex": 6, + "visibility": 30511.0, + "windDirection": 110, + "windGust": 16.27, + "windSpeed": 6.81 + }, + { + "forecastStart": "2023-09-16T02:00:00Z", + "cloudCover": 0.16, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1014.01, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.25, + "temperatureApparent": 33.46, + "temperatureDewPoint": 22.37, + "uvIndex": 8, + "visibility": 31232.0, + "windDirection": 30, + "windGust": 16.55, + "windSpeed": 6.86 + }, + { + "forecastStart": "2023-09-16T03:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.45, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.05, + "temperatureApparent": 34.18, + "temperatureDewPoint": 22.04, + "uvIndex": 8, + "visibility": 31751.0, + "windDirection": 17, + "windGust": 16.52, + "windSpeed": 6.8 + }, + { + "forecastStart": "2023-09-16T04:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.57, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.54, + "temperatureApparent": 34.67, + "temperatureDewPoint": 21.93, + "uvIndex": 8, + "visibility": 32057.0, + "windDirection": 17, + "windGust": 16.08, + "windSpeed": 6.62 + }, + { + "forecastStart": "2023-09-16T05:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.77, + "temperatureApparent": 34.92, + "temperatureDewPoint": 21.91, + "uvIndex": 6, + "visibility": 32148.0, + "windDirection": 20, + "windGust": 15.48, + "windSpeed": 6.45 + }, + { + "forecastStart": "2023-09-16T06:00:00Z", + "cloudCover": 0.1, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.56, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.11, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 31.44, + "temperatureApparent": 34.45, + "temperatureDewPoint": 21.72, + "uvIndex": 4, + "visibility": 32012.0, + "windDirection": 26, + "windGust": 15.08, + "windSpeed": 6.43 + }, + { + "forecastStart": "2023-09-16T07:00:00Z", + "cloudCover": 0.07, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.59, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.15, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.69, + "temperatureApparent": 33.61, + "temperatureDewPoint": 21.71, + "uvIndex": 2, + "visibility": 31608.0, + "windDirection": 39, + "windGust": 14.88, + "windSpeed": 6.61 + }, + { + "forecastStart": "2023-09-16T08:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.61, + "temperatureApparent": 32.49, + "temperatureDewPoint": 21.87, + "uvIndex": 0, + "visibility": 30972.0, + "windDirection": 72, + "windGust": 14.82, + "windSpeed": 6.95 + }, + { + "forecastStart": "2023-09-16T09:00:00Z", + "cloudCover": 0.02, + "conditionCode": "Clear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.75, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.54, + "temperatureApparent": 31.45, + "temperatureDewPoint": 22.15, + "uvIndex": 0, + "visibility": 30211.0, + "windDirection": 116, + "windGust": 15.13, + "windSpeed": 7.45 + }, + { + "forecastStart": "2023-09-16T10:00:00Z", + "cloudCover": 0.13, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.13, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.57, + "temperatureApparent": 30.46, + "temperatureDewPoint": 22.34, + "uvIndex": 0, + "visibility": 29403.0, + "windDirection": 140, + "windGust": 16.09, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T11:00:00Z", + "cloudCover": 0.31, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.47, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.87, + "temperatureApparent": 29.82, + "temperatureDewPoint": 22.62, + "uvIndex": 0, + "visibility": 28466.0, + "windDirection": 149, + "windGust": 17.37, + "windSpeed": 8.87 + }, + { + "forecastStart": "2023-09-16T12:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.29, + "temperatureApparent": 29.3, + "temperatureDewPoint": 22.89, + "uvIndex": 0, + "visibility": 27272.0, + "windDirection": 155, + "windGust": 18.29, + "windSpeed": 9.21 + }, + { + "forecastStart": "2023-09-16T13:00:00Z", + "cloudCover": 0.51, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.41, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.74, + "temperatureApparent": 28.73, + "temperatureDewPoint": 22.99, + "uvIndex": 0, + "visibility": 25405.0, + "windDirection": 159, + "windGust": 18.49, + "windSpeed": 8.96 + }, + { + "forecastStart": "2023-09-16T14:00:00Z", + "cloudCover": 0.55, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1013.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.02, + "temperatureApparent": 27.86, + "temperatureDewPoint": 22.82, + "uvIndex": 0, + "visibility": 22840.0, + "windDirection": 162, + "windGust": 18.47, + "windSpeed": 8.45 + }, + { + "forecastStart": "2023-09-16T15:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.55, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.48, + "temperatureApparent": 27.22, + "temperatureDewPoint": 22.73, + "uvIndex": 0, + "visibility": 20049.0, + "windDirection": 162, + "windGust": 18.79, + "windSpeed": 8.1 + }, + { + "forecastStart": "2023-09-16T16:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.1, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.03, + "temperatureApparent": 26.69, + "temperatureDewPoint": 22.65, + "uvIndex": 0, + "visibility": 17483.0, + "windDirection": 162, + "windGust": 19.81, + "windSpeed": 8.15 + }, + { + "forecastStart": "2023-09-16T17:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.68, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.69, + "temperatureApparent": 26.29, + "temperatureDewPoint": 22.6, + "uvIndex": 0, + "visibility": 15558.0, + "windDirection": 161, + "windGust": 20.96, + "windSpeed": 8.3 + }, + { + "forecastStart": "2023-09-16T18:00:00Z", + "cloudCover": 0.72, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.5, + "temperatureApparent": 26.01, + "temperatureDewPoint": 22.41, + "uvIndex": 0, + "visibility": 14707.0, + "windDirection": 159, + "windGust": 21.41, + "windSpeed": 8.24 + }, + { + "forecastStart": "2023-09-16T19:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.93, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.75, + "temperatureApparent": 26.33, + "temperatureDewPoint": 22.51, + "uvIndex": 0, + "visibility": 15332.0, + "windDirection": 159, + "windGust": 20.42, + "windSpeed": 7.62 + }, + { + "forecastStart": "2023-09-16T20:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.31, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.19, + "temperatureApparent": 26.84, + "temperatureDewPoint": 22.59, + "uvIndex": 0, + "visibility": 17205.0, + "windDirection": 158, + "windGust": 18.61, + "windSpeed": 6.66 + }, + { + "forecastStart": "2023-09-16T21:00:00Z", + "cloudCover": 0.58, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.87, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.92, + "temperatureApparent": 27.67, + "temperatureDewPoint": 22.64, + "uvIndex": 0, + "visibility": 19811.0, + "windDirection": 158, + "windGust": 17.14, + "windSpeed": 5.86 + }, + { + "forecastStart": "2023-09-16T22:00:00Z", + "cloudCover": 0.48, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.82, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.46, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.0, + "temperatureApparent": 28.85, + "temperatureDewPoint": 22.61, + "uvIndex": 1, + "visibility": 22602.0, + "windDirection": 161, + "windGust": 16.78, + "windSpeed": 5.5 + }, + { + "forecastStart": "2023-09-16T23:00:00Z", + "cloudCover": 0.39, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.51, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.47, + "temperatureApparent": 30.6, + "temperatureDewPoint": 22.86, + "uvIndex": 2, + "visibility": 24958.0, + "windDirection": 165, + "windGust": 17.21, + "windSpeed": 5.56 + }, + { + "forecastStart": "2023-09-17T00:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.71, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.39, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.49, + "temperatureApparent": 31.7, + "temperatureDewPoint": 22.77, + "uvIndex": 4, + "visibility": 26230.0, + "windDirection": 174, + "windGust": 17.96, + "windSpeed": 6.04 + }, + { + "forecastStart": "2023-09-17T01:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.35, + "temperatureApparent": 32.64, + "temperatureDewPoint": 22.73, + "uvIndex": 6, + "visibility": 26296.0, + "windDirection": 192, + "windGust": 19.15, + "windSpeed": 7.23 + }, + { + "forecastStart": "2023-09-17T02:00:00Z", + "cloudCover": 0.29, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.65, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1010.38, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.14, + "temperatureApparent": 33.56, + "temperatureDewPoint": 22.78, + "uvIndex": 7, + "visibility": 25582.0, + "windDirection": 225, + "windGust": 20.89, + "windSpeed": 8.9 + }, + { + "forecastStart": "2023-09-17T03:00:00Z", + "cloudCover": 0.3, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1009.75, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.66, + "temperatureApparent": 34.13, + "temperatureDewPoint": 22.76, + "uvIndex": 8, + "visibility": 24257.0, + "windDirection": 264, + "windGust": 22.67, + "windSpeed": 10.27 + }, + { + "forecastStart": "2023-09-17T04:00:00Z", + "cloudCover": 0.37, + "conditionCode": "MostlyClear", + "daylight": true, + "humidity": 0.62, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1009.18, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.54, + "temperatureApparent": 33.88, + "temperatureDewPoint": 22.54, + "uvIndex": 7, + "visibility": 22565.0, + "windDirection": 293, + "windGust": 23.93, + "windSpeed": 10.82 + }, + { + "forecastStart": "2023-09-17T05:00:00Z", + "cloudCover": 0.45, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.63, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.12, + "precipitationType": "rain", + "pressure": 1008.71, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 30.15, + "temperatureApparent": 33.36, + "temperatureDewPoint": 22.38, + "uvIndex": 5, + "visibility": 20796.0, + "windDirection": 308, + "windGust": 24.39, + "windSpeed": 10.72 + }, + { + "forecastStart": "2023-09-17T06:00:00Z", + "cloudCover": 0.5, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.64, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.46, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 29.62, + "temperatureApparent": 32.67, + "temperatureDewPoint": 22.19, + "uvIndex": 3, + "visibility": 19195.0, + "windDirection": 312, + "windGust": 23.9, + "windSpeed": 10.28 + }, + { + "forecastStart": "2023-09-17T07:00:00Z", + "cloudCover": 0.47, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.7, + "precipitationIntensity": 0.7, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1008.53, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.91, + "temperatureApparent": 31.84, + "temperatureDewPoint": 22.12, + "uvIndex": 1, + "visibility": 17604.0, + "windDirection": 312, + "windGust": 22.3, + "windSpeed": 9.59 + }, + { + "forecastStart": "2023-09-17T08:00:00Z", + "cloudCover": 0.41, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.6, + "precipitationIntensity": 0.6, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1008.82, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.91, + "temperatureApparent": 30.64, + "temperatureDewPoint": 21.93, + "uvIndex": 0, + "visibility": 15869.0, + "windDirection": 305, + "windGust": 19.73, + "windSpeed": 8.58 + }, + { + "forecastStart": "2023-09-17T09:00:00Z", + "cloudCover": 0.35, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.74, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1009.21, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.99, + "temperatureApparent": 29.64, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 14244.0, + "windDirection": 291, + "windGust": 16.49, + "windSpeed": 7.34 + }, + { + "forecastStart": "2023-09-17T10:00:00Z", + "cloudCover": 0.33, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.78, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1009.65, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.1, + "temperatureApparent": 28.63, + "temperatureDewPoint": 21.88, + "uvIndex": 0, + "visibility": 12808.0, + "windDirection": 257, + "windGust": 12.71, + "windSpeed": 5.91 + }, + { + "forecastStart": "2023-09-17T11:00:00Z", + "cloudCover": 0.34, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.82, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.14, + "precipitationType": "rain", + "pressure": 1010.04, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.29, + "temperatureApparent": 27.76, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 11601.0, + "windDirection": 212, + "windGust": 9.16, + "windSpeed": 4.54 + }, + { + "forecastStart": "2023-09-17T12:00:00Z", + "cloudCover": 0.36, + "conditionCode": "MostlyClear", + "daylight": false, + "humidity": 0.85, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.28, + "precipitationType": "rain", + "pressure": 1010.24, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.65, + "temperatureApparent": 27.06, + "temperatureDewPoint": 21.92, + "uvIndex": 0, + "visibility": 10807.0, + "windDirection": 192, + "windGust": 7.09, + "windSpeed": 3.62 + }, + { + "forecastStart": "2023-09-17T13:00:00Z", + "cloudCover": 0.4, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1010.15, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.15, + "temperatureApparent": 26.54, + "temperatureDewPoint": 21.96, + "uvIndex": 0, + "visibility": 10514.0, + "windDirection": 185, + "windGust": 7.2, + "windSpeed": 3.27 + }, + { + "forecastStart": "2023-09-17T14:00:00Z", + "cloudCover": 0.44, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.3, + "precipitationType": "rain", + "pressure": 1009.87, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.6, + "temperatureApparent": 25.87, + "temperatureDewPoint": 21.79, + "uvIndex": 0, + "visibility": 10700.0, + "windDirection": 182, + "windGust": 8.37, + "windSpeed": 3.22 + }, + { + "forecastStart": "2023-09-17T15:00:00Z", + "cloudCover": 0.49, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.31, + "precipitationType": "rain", + "pressure": 1009.56, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.21, + "temperatureApparent": 25.46, + "temperatureDewPoint": 21.84, + "uvIndex": 0, + "visibility": 11364.0, + "windDirection": 180, + "windGust": 9.21, + "windSpeed": 3.3 + }, + { + "forecastStart": "2023-09-17T16:00:00Z", + "cloudCover": 0.53, + "conditionCode": "Drizzle", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.33, + "precipitationType": "rain", + "pressure": 1009.29, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.87, + "temperatureApparent": 25.08, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 12623.0, + "windDirection": 182, + "windGust": 9.0, + "windSpeed": 3.46 + }, + { + "forecastStart": "2023-09-17T17:00:00Z", + "cloudCover": 0.56, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.35, + "precipitationType": "clear", + "pressure": 1009.09, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.62, + "temperatureApparent": 24.79, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 14042.0, + "windDirection": 186, + "windGust": 8.37, + "windSpeed": 3.72 + }, + { + "forecastStart": "2023-09-17T18:00:00Z", + "cloudCover": 0.59, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.95, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.37, + "precipitationType": "clear", + "pressure": 1009.01, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.47, + "temperatureApparent": 24.57, + "temperatureDewPoint": 21.59, + "uvIndex": 0, + "visibility": 14809.0, + "windDirection": 201, + "windGust": 7.99, + "windSpeed": 4.07 + }, + { + "forecastStart": "2023-09-17T19:00:00Z", + "cloudCover": 0.62, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.94, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.07, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.68, + "temperatureApparent": 24.85, + "temperatureDewPoint": 21.73, + "uvIndex": 0, + "visibility": 14586.0, + "windDirection": 258, + "windGust": 8.18, + "windSpeed": 4.55 + }, + { + "forecastStart": "2023-09-17T20:00:00Z", + "cloudCover": 0.64, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.92, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.39, + "precipitationType": "clear", + "pressure": 1009.23, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.01, + "temperatureApparent": 25.2, + "temperatureDewPoint": 21.71, + "uvIndex": 0, + "visibility": 13831.0, + "windDirection": 305, + "windGust": 8.77, + "windSpeed": 5.17 + }, + { + "forecastStart": "2023-09-17T21:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.38, + "precipitationType": "clear", + "pressure": 1009.47, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.51, + "temperatureApparent": 25.77, + "temperatureDewPoint": 21.77, + "uvIndex": 0, + "visibility": 12945.0, + "windDirection": 318, + "windGust": 9.69, + "windSpeed": 5.77 + }, + { + "forecastStart": "2023-09-17T22:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.3, + "precipitationType": "clear", + "pressure": 1009.77, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.21, + "temperatureApparent": 26.53, + "temperatureDewPoint": 21.79, + "uvIndex": 1, + "visibility": 12093.0, + "windDirection": 324, + "windGust": 10.88, + "windSpeed": 6.26 + }, + { + "forecastStart": "2023-09-17T23:00:00Z", + "cloudCover": 0.8, + "conditionCode": "Drizzle", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.09, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.08, + "temperatureApparent": 27.55, + "temperatureDewPoint": 21.95, + "uvIndex": 2, + "visibility": 11231.0, + "windDirection": 329, + "windGust": 12.21, + "windSpeed": 6.68 + }, + { + "forecastStart": "2023-09-18T00:00:00Z", + "cloudCover": 0.87, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.8, + "precipitationAmount": 0.2, + "precipitationIntensity": 0.2, + "precipitationChance": 0.15, + "precipitationType": "rain", + "pressure": 1010.33, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.71, + "temperatureApparent": 28.22, + "temperatureDewPoint": 21.92, + "uvIndex": 3, + "visibility": 10426.0, + "windDirection": 332, + "windGust": 13.52, + "windSpeed": 7.12 + }, + { + "forecastStart": "2023-09-18T01:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.72, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1007.43, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.19, + "temperatureApparent": 29.75, + "temperatureDewPoint": 21.7, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 330, + "windGust": 11.36, + "windSpeed": 11.36 + }, + { + "forecastStart": "2023-09-18T02:00:00Z", + "cloudCover": 0.7, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.7, + "precipitationAmount": 0.3, + "precipitationIntensity": 0.3, + "precipitationChance": 0.09, + "precipitationType": "rain", + "pressure": 1007.05, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.51, + "temperatureApparent": 30.07, + "temperatureDewPoint": 21.64, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 332, + "windGust": 12.06, + "windSpeed": 12.06 + }, + { + "forecastStart": "2023-09-18T03:00:00Z", + "cloudCover": 0.71, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.5, + "precipitationIntensity": 0.5, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.75, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.59, + "uvIndex": 6, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 12.81, + "windSpeed": 12.81 + }, + { + "forecastStart": "2023-09-18T04:00:00Z", + "cloudCover": 0.67, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.68, + "precipitationAmount": 0.4, + "precipitationIntensity": 0.4, + "precipitationChance": 0.1, + "precipitationType": "rain", + "pressure": 1006.28, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.99, + "temperatureApparent": 30.55, + "temperatureDewPoint": 21.53, + "uvIndex": 5, + "visibility": 24135.0, + "windDirection": 335, + "windGust": 13.68, + "windSpeed": 13.68 + }, + { + "forecastStart": "2023-09-18T05:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1005.89, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 28.15, + "temperatureApparent": 30.66, + "temperatureDewPoint": 21.4, + "uvIndex": 4, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.61, + "windSpeed": 14.61 + }, + { + "forecastStart": "2023-09-18T06:00:00Z", + "cloudCover": 0.57, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.67, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.27, + "precipitationType": "clear", + "pressure": 1005.67, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.92, + "temperatureApparent": 30.31, + "temperatureDewPoint": 21.18, + "uvIndex": 3, + "visibility": 24135.0, + "windDirection": 338, + "windGust": 15.25, + "windSpeed": 15.25 + }, + { + "forecastStart": "2023-09-18T07:00:00Z", + "cloudCover": 0.6, + "conditionCode": "PartlyCloudy", + "daylight": true, + "humidity": 0.69, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.28, + "precipitationType": "clear", + "pressure": 1005.74, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 27.4, + "temperatureApparent": 29.78, + "temperatureDewPoint": 21.26, + "uvIndex": 1, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.45, + "windSpeed": 15.45 + }, + { + "forecastStart": "2023-09-18T08:00:00Z", + "cloudCover": 0.65, + "conditionCode": "MostlyCloudy", + "daylight": true, + "humidity": 0.73, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1005.98, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.73, + "temperatureApparent": 29.13, + "temperatureDewPoint": 21.44, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.38, + "windSpeed": 15.38 + }, + { + "forecastStart": "2023-09-18T09:00:00Z", + "cloudCover": 0.68, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.76, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.22, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 26.12, + "temperatureApparent": 28.55, + "temperatureDewPoint": 21.64, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 341, + "windGust": 15.27, + "windSpeed": 15.27 + }, + { + "forecastStart": "2023-09-18T10:00:00Z", + "cloudCover": 0.66, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.79, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1006.44, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.56, + "temperatureApparent": 27.93, + "temperatureDewPoint": 21.61, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 339, + "windGust": 15.09, + "windSpeed": 15.09 + }, + { + "forecastStart": "2023-09-18T11:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.81, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.66, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 25.19, + "temperatureApparent": 27.58, + "temperatureDewPoint": 21.74, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 336, + "windGust": 14.88, + "windSpeed": 14.88 + }, + { + "forecastStart": "2023-09-18T12:00:00Z", + "cloudCover": 0.61, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.26, + "precipitationType": "clear", + "pressure": 1006.79, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 24.83, + "temperatureApparent": 27.2, + "temperatureDewPoint": 21.78, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 333, + "windGust": 14.91, + "windSpeed": 14.91 + }, + { + "forecastStart": "2023-09-18T13:00:00Z", + "cloudCover": 0.38, + "conditionCode": "PartlyCloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1012.36, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.63, + "temperatureApparent": 25.69, + "temperatureDewPoint": 21.23, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 83, + "windGust": 4.58, + "windSpeed": 3.16 + }, + { + "forecastStart": "2023-09-18T14:00:00Z", + "cloudCover": 0.74, + "conditionCode": "MostlyCloudy", + "daylight": false, + "humidity": 0.89, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.96, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.13, + "temperatureApparent": 25.13, + "temperatureDewPoint": 21.18, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 144, + "windGust": 4.74, + "windSpeed": 4.52 + }, + { + "forecastStart": "2023-09-18T15:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.6, + "pressureTrend": "rising", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.6, + "temperatureApparent": 24.48, + "temperatureDewPoint": 20.95, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 152, + "windGust": 5.63, + "windSpeed": 5.63 + }, + { + "forecastStart": "2023-09-18T16:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.37, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.27, + "temperatureApparent": 24.04, + "temperatureDewPoint": 20.69, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 156, + "windGust": 6.02, + "windSpeed": 6.02 + }, + { + "forecastStart": "2023-09-18T17:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.91, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.2, + "pressureTrend": "falling", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.02, + "temperatureApparent": 23.69, + "temperatureDewPoint": 20.45, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 6.15, + "windSpeed": 6.15 + }, + { + "forecastStart": "2023-09-18T18:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.9, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.08, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.88, + "temperatureApparent": 23.45, + "temperatureDewPoint": 20.16, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 167, + "windGust": 6.48, + "windSpeed": 6.48 + }, + { + "forecastStart": "2023-09-18T19:00:00Z", + "cloudCover": 1.0, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.88, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.04, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.76, + "temperatureApparent": 23.19, + "temperatureDewPoint": 19.76, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 165, + "windGust": 7.51, + "windSpeed": 7.51 + }, + { + "forecastStart": "2023-09-18T20:00:00Z", + "cloudCover": 0.99, + "conditionCode": "Cloudy", + "daylight": false, + "humidity": 0.86, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.05, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 21.96, + "temperatureApparent": 23.35, + "temperatureDewPoint": 19.58, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 162, + "windGust": 8.73, + "windSpeed": 8.73 + }, + { + "forecastStart": "2023-09-18T21:00:00Z", + "cloudCover": 0.98, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.83, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.06, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 22.53, + "temperatureApparent": 23.93, + "temperatureDewPoint": 19.54, + "uvIndex": 0, + "visibility": 24135.0, + "windDirection": 164, + "windGust": 9.21, + "windSpeed": 9.11 + }, + { + "forecastStart": "2023-09-18T22:00:00Z", + "cloudCover": 0.96, + "conditionCode": "Cloudy", + "daylight": true, + "humidity": 0.78, + "precipitationAmount": 0.0, + "precipitationIntensity": 0.0, + "precipitationChance": 0.0, + "precipitationType": "clear", + "pressure": 1011.09, + "pressureTrend": "steady", + "snowfallIntensity": 0.0, + "snowfallAmount": 0.0, + "temperature": 23.8, + "temperatureApparent": 25.34, + "temperatureDewPoint": 19.73, + "uvIndex": 1, + "visibility": 24204.0, + "windDirection": 171, + "windGust": 9.03, + "windSpeed": 7.91 + } + ] + } +} diff --git a/tests/components/weatherkit/snapshots/test_weather.ambr b/tests/components/weatherkit/snapshots/test_weather.ambr new file mode 100644 index 00000000000000..63321b5a81321f --- /dev/null +++ b/tests/components/weatherkit/snapshots/test_weather.ambr @@ -0,0 +1,4087 @@ +# serializer version: 1 +# name: test_daily_forecast + dict({ + 'forecast': list([ + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 28.6, + 'templow': 21.2, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-09T15:00:00Z', + 'precipitation': 3.6, + 'precipitation_probability': 45.0, + 'temperature': 30.6, + 'templow': 21.0, + 'uv_index': 6, + }), + dict({ + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-11T15:00:00Z', + 'precipitation': 0.7, + 'precipitation_probability': 47.0, + 'temperature': 30.4, + 'templow': 23.1, + 'uv_index': 5, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-12T15:00:00Z', + 'precipitation': 7.7, + 'precipitation_probability': 37.0, + 'temperature': 30.4, + 'templow': 22.1, + 'uv_index': 6, + }), + dict({ + 'condition': 'rainy', + 'datetime': '2023-09-13T15:00:00Z', + 'precipitation': 0.6, + 'precipitation_probability': 45.0, + 'temperature': 31.0, + 'templow': 22.6, + 'uv_index': 6, + }), + dict({ + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'temperature': 31.5, + 'templow': 22.4, + 'uv_index': 7, + }), + dict({ + 'condition': 'sunny', + 'datetime': '2023-09-15T15:00:00Z', + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'temperature': 31.8, + 'templow': 23.3, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-16T15:00:00Z', + 'precipitation': 5.3, + 'precipitation_probability': 35.0, + 'temperature': 30.7, + 'templow': 23.2, + 'uv_index': 8, + }), + dict({ + 'condition': 'lightning', + 'datetime': '2023-09-17T15:00:00Z', + 'precipitation': 2.1, + 'precipitation_probability': 49.0, + 'temperature': 28.1, + 'templow': 22.5, + 'uv_index': 6, + }), + ]), + }) +# --- +# name: test_hourly_forecast + dict({ + 'forecast': list([ + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T14:00:00Z', + 'dew_point': 21.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 264, + 'wind_gust_speed': 13.44, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 80.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.24, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 261, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.64, + }), + dict({ + 'apparent_temperature': 23.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.12, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 252, + 'wind_gust_speed': 11.15, + 'wind_speed': 6.14, + }), + dict({ + 'apparent_temperature': 23.5, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.7, + 'uv_index': 0, + 'wind_bearing': 248, + 'wind_gust_speed': 11.57, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T18:00:00Z', + 'dew_point': 20.8, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.05, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 12.42, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 23.0, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.03, + 'temperature': 21.3, + 'uv_index': 0, + 'wind_bearing': 224, + 'wind_gust_speed': 11.3, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T20:00:00Z', + 'dew_point': 20.4, + 'humidity': 96, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.31, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 221, + 'wind_gust_speed': 10.57, + 'wind_speed': 5.13, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T21:00:00Z', + 'dew_point': 20.5, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.55, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 237, + 'wind_gust_speed': 10.63, + 'wind_speed': 5.7, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-08T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.79, + 'temperature': 22.8, + 'uv_index': 1, + 'wind_bearing': 258, + 'wind_gust_speed': 10.47, + 'wind_speed': 5.22, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-08T23:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.95, + 'temperature': 24.0, + 'uv_index': 2, + 'wind_bearing': 282, + 'wind_gust_speed': 12.74, + 'wind_speed': 5.71, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T00:00:00Z', + 'dew_point': 21.5, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.35, + 'temperature': 25.1, + 'uv_index': 3, + 'wind_bearing': 294, + 'wind_gust_speed': 13.87, + 'wind_speed': 6.53, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T01:00:00Z', + 'dew_point': 21.8, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 26.5, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 16.04, + 'wind_speed': 6.54, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T02:00:00Z', + 'dew_point': 22.0, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.23, + 'temperature': 27.6, + 'uv_index': 6, + 'wind_bearing': 314, + 'wind_gust_speed': 18.1, + 'wind_speed': 7.32, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T03:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.86, + 'temperature': 28.3, + 'uv_index': 6, + 'wind_bearing': 317, + 'wind_gust_speed': 20.77, + 'wind_speed': 9.1, + }), + dict({ + 'apparent_temperature': 31.5, + 'cloud_coverage': 69.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T04:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.65, + 'temperature': 28.6, + 'uv_index': 6, + 'wind_bearing': 311, + 'wind_gust_speed': 21.27, + 'wind_speed': 10.21, + }), + dict({ + 'apparent_temperature': 31.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T05:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.48, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 317, + 'wind_gust_speed': 19.62, + 'wind_speed': 10.53, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.54, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 335, + 'wind_gust_speed': 18.98, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.76, + 'temperature': 27.1, + 'uv_index': 2, + 'wind_bearing': 338, + 'wind_gust_speed': 17.04, + 'wind_speed': 7.75, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.05, + 'temperature': 26.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 14.75, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 344, + 'wind_gust_speed': 10.43, + 'wind_speed': 5.2, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.73, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 6.95, + 'wind_speed': 3.59, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 326, + 'wind_gust_speed': 5.27, + 'wind_speed': 2.1, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.52, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 5.48, + 'wind_speed': 0.93, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T13:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 188, + 'wind_gust_speed': 4.44, + 'wind_speed': 1.79, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 4.49, + 'wind_speed': 2.19, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T15:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.21, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 179, + 'wind_gust_speed': 5.32, + 'wind_speed': 2.65, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T16:00:00Z', + 'dew_point': 21.1, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 173, + 'wind_gust_speed': 5.81, + 'wind_speed': 3.2, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T17:00:00Z', + 'dew_point': 20.9, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.88, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 5.53, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 23.3, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.94, + 'temperature': 21.6, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 6.09, + 'wind_speed': 3.36, + }), + dict({ + 'apparent_temperature': 23.1, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T19:00:00Z', + 'dew_point': 20.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.96, + 'temperature': 21.4, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 6.83, + 'wind_speed': 3.71, + }), + dict({ + 'apparent_temperature': 22.5, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T20:00:00Z', + 'dew_point': 20.0, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 21.0, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 7.98, + 'wind_speed': 4.27, + }), + dict({ + 'apparent_temperature': 22.8, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T21:00:00Z', + 'dew_point': 20.2, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.61, + 'temperature': 21.2, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 8.4, + 'wind_speed': 4.69, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-09T22:00:00Z', + 'dew_point': 21.3, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.87, + 'temperature': 23.1, + 'uv_index': 1, + 'wind_bearing': 150, + 'wind_gust_speed': 7.66, + 'wind_speed': 4.33, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-09T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 123, + 'wind_gust_speed': 9.63, + 'wind_speed': 3.91, + }), + dict({ + 'apparent_temperature': 30.4, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 105, + 'wind_gust_speed': 12.59, + 'wind_speed': 3.96, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T01:00:00Z', + 'dew_point': 22.9, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.79, + 'temperature': 28.9, + 'uv_index': 5, + 'wind_bearing': 99, + 'wind_gust_speed': 14.17, + 'wind_speed': 4.06, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T02:00:00Z', + 'dew_point': 22.9, + 'humidity': 66, + 'precipitation': 0.3, + 'precipitation_probability': 7.000000000000001, + 'pressure': 1011.29, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 93, + 'wind_gust_speed': 17.75, + 'wind_speed': 4.87, + }), + dict({ + 'apparent_temperature': 34.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T03:00:00Z', + 'dew_point': 23.1, + 'humidity': 64, + 'precipitation': 0.3, + 'precipitation_probability': 11.0, + 'pressure': 1010.78, + 'temperature': 30.6, + 'uv_index': 6, + 'wind_bearing': 78, + 'wind_gust_speed': 17.43, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T04:00:00Z', + 'dew_point': 23.2, + 'humidity': 66, + 'precipitation': 0.4, + 'precipitation_probability': 15.0, + 'pressure': 1010.37, + 'temperature': 30.3, + 'uv_index': 5, + 'wind_bearing': 60, + 'wind_gust_speed': 15.24, + 'wind_speed': 4.9, + }), + dict({ + 'apparent_temperature': 33.7, + 'cloud_coverage': 79.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T05:00:00Z', + 'dew_point': 23.3, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 17.0, + 'pressure': 1010.09, + 'temperature': 30.0, + 'uv_index': 4, + 'wind_bearing': 80, + 'wind_gust_speed': 13.53, + 'wind_speed': 5.98, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T06:00:00Z', + 'dew_point': 23.4, + 'humidity': 70, + 'precipitation': 1.0, + 'precipitation_probability': 17.0, + 'pressure': 1010.0, + 'temperature': 29.5, + 'uv_index': 3, + 'wind_bearing': 83, + 'wind_gust_speed': 12.55, + 'wind_speed': 6.84, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 88.0, + 'condition': 'rainy', + 'datetime': '2023-09-10T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 73, + 'precipitation': 0.4, + 'precipitation_probability': 16.0, + 'pressure': 1010.27, + 'temperature': 28.7, + 'uv_index': 2, + 'wind_bearing': 90, + 'wind_gust_speed': 10.16, + 'wind_speed': 6.07, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T08:00:00Z', + 'dew_point': 23.2, + 'humidity': 77, + 'precipitation': 0.5, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.71, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 101, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.82, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 93.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T09:00:00Z', + 'dew_point': 23.2, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.9, + 'temperature': 26.5, + 'uv_index': 0, + 'wind_bearing': 128, + 'wind_gust_speed': 8.89, + 'wind_speed': 4.95, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T10:00:00Z', + 'dew_point': 23.0, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.12, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 134, + 'wind_gust_speed': 10.03, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.43, + 'temperature': 25.1, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 12.4, + 'wind_speed': 5.41, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T12:00:00Z', + 'dew_point': 22.5, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.58, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 16.36, + 'wind_speed': 6.31, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T13:00:00Z', + 'dew_point': 22.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 19.66, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.4, + 'temperature': 24.3, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 21.15, + 'wind_speed': 7.46, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T15:00:00Z', + 'dew_point': 22.0, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.26, + 'wind_speed': 7.84, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.01, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 23.53, + 'wind_speed': 8.63, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-10T17:00:00Z', + 'dew_point': 21.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.78, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 22.83, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T18:00:00Z', + 'dew_point': 21.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.69, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.7, + 'wind_speed': 8.7, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T19:00:00Z', + 'dew_point': 21.4, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.77, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 24.24, + 'wind_speed': 8.74, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.89, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 23.99, + 'wind_speed': 8.81, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T21:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.1, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 25.55, + 'wind_speed': 9.05, + }), + dict({ + 'apparent_temperature': 27.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 24.6, + 'uv_index': 1, + 'wind_bearing': 140, + 'wind_gust_speed': 29.08, + 'wind_speed': 10.37, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-10T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.36, + 'temperature': 25.9, + 'uv_index': 2, + 'wind_bearing': 140, + 'wind_gust_speed': 34.13, + 'wind_speed': 12.56, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T00:00:00Z', + 'dew_point': 22.3, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 27.2, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 38.2, + 'wind_speed': 15.65, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T01:00:00Z', + 'dew_point': 22.3, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 37.55, + 'wind_speed': 15.78, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 143, + 'wind_gust_speed': 35.86, + 'wind_speed': 15.41, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T03:00:00Z', + 'dew_point': 22.5, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.61, + 'temperature': 30.3, + 'uv_index': 6, + 'wind_bearing': 141, + 'wind_gust_speed': 35.88, + 'wind_speed': 15.51, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T04:00:00Z', + 'dew_point': 22.6, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.36, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 140, + 'wind_gust_speed': 35.99, + 'wind_speed': 15.75, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T05:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.11, + 'temperature': 30.1, + 'uv_index': 4, + 'wind_bearing': 137, + 'wind_gust_speed': 33.61, + 'wind_speed': 15.36, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T06:00:00Z', + 'dew_point': 22.5, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1009.98, + 'temperature': 30.0, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 32.61, + 'wind_speed': 14.98, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T07:00:00Z', + 'dew_point': 22.2, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.13, + 'temperature': 29.2, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 28.1, + 'wind_speed': 13.88, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T08:00:00Z', + 'dew_point': 22.1, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.48, + 'temperature': 28.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 24.22, + 'wind_speed': 13.02, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-11T09:00:00Z', + 'dew_point': 21.9, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.81, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 22.5, + 'wind_speed': 11.94, + }), + dict({ + 'apparent_temperature': 28.8, + 'cloud_coverage': 63.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T10:00:00Z', + 'dew_point': 21.7, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 137, + 'wind_gust_speed': 21.47, + 'wind_speed': 11.25, + }), + dict({ + 'apparent_temperature': 28.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T11:00:00Z', + 'dew_point': 21.8, + 'humidity': 80, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 22.71, + 'wind_speed': 12.39, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 23.67, + 'wind_speed': 12.83, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T13:00:00Z', + 'dew_point': 21.7, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.97, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 23.34, + 'wind_speed': 12.62, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T14:00:00Z', + 'dew_point': 21.7, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.83, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.9, + 'wind_speed': 12.07, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T15:00:00Z', + 'dew_point': 21.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.74, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 22.01, + 'wind_speed': 11.19, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T16:00:00Z', + 'dew_point': 21.6, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.56, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 21.29, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T17:00:00Z', + 'dew_point': 21.5, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.35, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 20.52, + 'wind_speed': 10.5, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.3, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 20.04, + 'wind_speed': 10.51, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T19:00:00Z', + 'dew_point': 21.3, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 12.0, + 'pressure': 1011.37, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 18.07, + 'wind_speed': 10.13, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T20:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.2, + 'precipitation_probability': 13.0, + 'pressure': 1011.53, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 16.86, + 'wind_speed': 10.34, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T21:00:00Z', + 'dew_point': 21.4, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.71, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 138, + 'wind_gust_speed': 16.66, + 'wind_speed': 10.68, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T22:00:00Z', + 'dew_point': 21.9, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 24.4, + 'uv_index': 1, + 'wind_bearing': 137, + 'wind_gust_speed': 17.21, + 'wind_speed': 10.61, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 78.0, + 'condition': 'cloudy', + 'datetime': '2023-09-11T23:00:00Z', + 'dew_point': 22.3, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.05, + 'temperature': 25.6, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 19.23, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 29.5, + 'cloud_coverage': 79.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T00:00:00Z', + 'dew_point': 22.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.07, + 'temperature': 26.6, + 'uv_index': 3, + 'wind_bearing': 140, + 'wind_gust_speed': 20.61, + 'wind_speed': 11.13, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 82.0, + 'condition': 'rainy', + 'datetime': '2023-09-12T01:00:00Z', + 'dew_point': 23.1, + 'humidity': 75, + 'precipitation': 0.2, + 'precipitation_probability': 16.0, + 'pressure': 1011.89, + 'temperature': 27.9, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 23.35, + 'wind_speed': 11.98, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 85.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.53, + 'temperature': 29.0, + 'uv_index': 5, + 'wind_bearing': 143, + 'wind_gust_speed': 26.45, + 'wind_speed': 13.01, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.15, + 'temperature': 29.8, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 28.95, + 'wind_speed': 13.9, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.79, + 'temperature': 30.2, + 'uv_index': 5, + 'wind_bearing': 141, + 'wind_gust_speed': 27.9, + 'wind_speed': 13.95, + }), + dict({ + 'apparent_temperature': 34.0, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T05:00:00Z', + 'dew_point': 23.1, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.43, + 'temperature': 30.4, + 'uv_index': 4, + 'wind_bearing': 140, + 'wind_gust_speed': 26.53, + 'wind_speed': 13.78, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T06:00:00Z', + 'dew_point': 22.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.21, + 'temperature': 30.1, + 'uv_index': 3, + 'wind_bearing': 138, + 'wind_gust_speed': 24.56, + 'wind_speed': 13.74, + }), + dict({ + 'apparent_temperature': 32.0, + 'cloud_coverage': 53.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.26, + 'temperature': 29.1, + 'uv_index': 2, + 'wind_bearing': 138, + 'wind_gust_speed': 22.78, + 'wind_speed': 13.21, + }), + dict({ + 'apparent_temperature': 30.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.51, + 'temperature': 28.1, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 19.92, + 'wind_speed': 12.0, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T09:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.8, + 'temperature': 27.2, + 'uv_index': 0, + 'wind_bearing': 141, + 'wind_gust_speed': 17.65, + 'wind_speed': 10.97, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T10:00:00Z', + 'dew_point': 21.4, + 'humidity': 75, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.23, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 143, + 'wind_gust_speed': 15.87, + 'wind_speed': 10.23, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T11:00:00Z', + 'dew_point': 21.3, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1011.79, + 'temperature': 25.4, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 13.9, + 'wind_speed': 9.39, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-12T12:00:00Z', + 'dew_point': 21.2, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 47.0, + 'pressure': 1012.12, + 'temperature': 24.7, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.32, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1012.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.18, + 'wind_speed': 8.59, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T14:00:00Z', + 'dew_point': 21.3, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.09, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 13.84, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T15:00:00Z', + 'dew_point': 21.3, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.99, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.93, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T16:00:00Z', + 'dew_point': 21.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 16.74, + 'wind_speed': 9.49, + }), + dict({ + 'apparent_temperature': 24.7, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T17:00:00Z', + 'dew_point': 20.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.75, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 146, + 'wind_gust_speed': 17.45, + 'wind_speed': 9.12, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T18:00:00Z', + 'dew_point': 20.7, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.77, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.04, + 'wind_speed': 8.68, + }), + dict({ + 'apparent_temperature': 24.1, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T19:00:00Z', + 'dew_point': 20.6, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.93, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 16.8, + 'wind_speed': 8.61, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T20:00:00Z', + 'dew_point': 20.5, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.23, + 'temperature': 22.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.35, + 'wind_speed': 8.36, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 75.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T21:00:00Z', + 'dew_point': 20.6, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.49, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 14.09, + 'wind_speed': 7.77, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T22:00:00Z', + 'dew_point': 21.0, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.72, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 152, + 'wind_gust_speed': 14.04, + 'wind_speed': 7.25, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-12T23:00:00Z', + 'dew_point': 21.4, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 25.5, + 'uv_index': 2, + 'wind_bearing': 149, + 'wind_gust_speed': 15.31, + 'wind_speed': 7.14, + }), + dict({ + 'apparent_temperature': 29.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-13T00:00:00Z', + 'dew_point': 21.8, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 27.1, + 'uv_index': 4, + 'wind_bearing': 141, + 'wind_gust_speed': 16.42, + 'wind_speed': 6.89, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T01:00:00Z', + 'dew_point': 22.0, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.65, + 'temperature': 28.4, + 'uv_index': 5, + 'wind_bearing': 137, + 'wind_gust_speed': 18.64, + 'wind_speed': 6.65, + }), + dict({ + 'apparent_temperature': 32.3, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T02:00:00Z', + 'dew_point': 21.9, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.26, + 'temperature': 29.4, + 'uv_index': 5, + 'wind_bearing': 128, + 'wind_gust_speed': 21.69, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 33.0, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T03:00:00Z', + 'dew_point': 21.9, + 'humidity': 62, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.88, + 'temperature': 30.1, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 23.41, + 'wind_speed': 7.33, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T04:00:00Z', + 'dew_point': 22.0, + 'humidity': 61, + 'precipitation': 0.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.55, + 'temperature': 30.4, + 'uv_index': 5, + 'wind_bearing': 56, + 'wind_gust_speed': 23.1, + 'wind_speed': 8.09, + }), + dict({ + 'apparent_temperature': 33.2, + 'cloud_coverage': 72.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 61, + 'precipitation': 1.9, + 'precipitation_probability': 12.0, + 'pressure': 1011.29, + 'temperature': 30.2, + 'uv_index': 4, + 'wind_bearing': 20, + 'wind_gust_speed': 21.81, + 'wind_speed': 9.46, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 74.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T06:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 2.3, + 'precipitation_probability': 11.0, + 'pressure': 1011.17, + 'temperature': 29.7, + 'uv_index': 3, + 'wind_bearing': 20, + 'wind_gust_speed': 19.72, + 'wind_speed': 9.8, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 69.0, + 'condition': 'rainy', + 'datetime': '2023-09-13T07:00:00Z', + 'dew_point': 22.4, + 'humidity': 68, + 'precipitation': 1.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.32, + 'temperature': 28.8, + 'uv_index': 1, + 'wind_bearing': 18, + 'wind_gust_speed': 17.55, + 'wind_speed': 9.23, + }), + dict({ + 'apparent_temperature': 30.8, + 'cloud_coverage': 73.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T08:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.8, + 'precipitation_probability': 10.0, + 'pressure': 1011.6, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 27, + 'wind_gust_speed': 15.08, + 'wind_speed': 8.05, + }), + dict({ + 'apparent_temperature': 29.4, + 'cloud_coverage': 76.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T09:00:00Z', + 'dew_point': 23.0, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.94, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 32, + 'wind_gust_speed': 12.17, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T10:00:00Z', + 'dew_point': 22.9, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.3, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 69, + 'wind_gust_speed': 11.64, + 'wind_speed': 6.69, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.71, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 11.91, + 'wind_speed': 6.23, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.96, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.47, + 'wind_speed': 5.73, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 82.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T13:00:00Z', + 'dew_point': 22.3, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.03, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 13.57, + 'wind_speed': 5.66, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 84.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T14:00:00Z', + 'dew_point': 22.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.99, + 'temperature': 23.9, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 15.07, + 'wind_speed': 5.83, + }), + dict({ + 'apparent_temperature': 26.1, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T15:00:00Z', + 'dew_point': 22.2, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.95, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 16.06, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 88.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T16:00:00Z', + 'dew_point': 22.0, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.9, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 153, + 'wind_gust_speed': 16.05, + 'wind_speed': 5.75, + }), + dict({ + 'apparent_temperature': 25.4, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T17:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.85, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 150, + 'wind_gust_speed': 15.52, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 92.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T18:00:00Z', + 'dew_point': 21.8, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.87, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 15.01, + 'wind_speed': 5.32, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 90.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 22.8, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.39, + 'wind_speed': 5.33, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 89.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T20:00:00Z', + 'dew_point': 21.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.22, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 13.79, + 'wind_speed': 5.43, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 86.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 147, + 'wind_gust_speed': 14.12, + 'wind_speed': 5.52, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 77.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T22:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.59, + 'temperature': 24.3, + 'uv_index': 1, + 'wind_bearing': 147, + 'wind_gust_speed': 16.14, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-13T23:00:00Z', + 'dew_point': 22.4, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.74, + 'temperature': 25.7, + 'uv_index': 2, + 'wind_bearing': 146, + 'wind_gust_speed': 19.09, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.78, + 'temperature': 27.4, + 'uv_index': 4, + 'wind_bearing': 143, + 'wind_gust_speed': 21.6, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 32.2, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T01:00:00Z', + 'dew_point': 23.2, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.61, + 'temperature': 28.7, + 'uv_index': 5, + 'wind_bearing': 138, + 'wind_gust_speed': 23.36, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 54.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T02:00:00Z', + 'dew_point': 23.2, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.32, + 'temperature': 29.9, + 'uv_index': 6, + 'wind_bearing': 111, + 'wind_gust_speed': 24.72, + 'wind_speed': 4.99, + }), + dict({ + 'apparent_temperature': 34.4, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T03:00:00Z', + 'dew_point': 23.3, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.04, + 'temperature': 30.7, + 'uv_index': 6, + 'wind_bearing': 354, + 'wind_gust_speed': 25.23, + 'wind_speed': 4.74, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T04:00:00Z', + 'dew_point': 23.4, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.77, + 'temperature': 31.0, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 24.6, + 'wind_speed': 4.79, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 60.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T05:00:00Z', + 'dew_point': 23.2, + 'humidity': 64, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1012.53, + 'temperature': 30.7, + 'uv_index': 5, + 'wind_bearing': 336, + 'wind_gust_speed': 23.28, + 'wind_speed': 5.07, + }), + dict({ + 'apparent_temperature': 33.8, + 'cloud_coverage': 59.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T06:00:00Z', + 'dew_point': 23.1, + 'humidity': 66, + 'precipitation': 0.2, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1012.49, + 'temperature': 30.2, + 'uv_index': 3, + 'wind_bearing': 336, + 'wind_gust_speed': 22.05, + 'wind_speed': 5.34, + }), + dict({ + 'apparent_temperature': 32.9, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-14T07:00:00Z', + 'dew_point': 23.0, + 'humidity': 68, + 'precipitation': 0.2, + 'precipitation_probability': 40.0, + 'pressure': 1012.73, + 'temperature': 29.5, + 'uv_index': 2, + 'wind_bearing': 339, + 'wind_gust_speed': 21.18, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 31.6, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T08:00:00Z', + 'dew_point': 22.8, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 45.0, + 'pressure': 1013.16, + 'temperature': 28.4, + 'uv_index': 0, + 'wind_bearing': 342, + 'wind_gust_speed': 20.35, + 'wind_speed': 5.93, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T09:00:00Z', + 'dew_point': 22.5, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1013.62, + 'temperature': 27.1, + 'uv_index': 0, + 'wind_bearing': 347, + 'wind_gust_speed': 19.42, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 29.0, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T10:00:00Z', + 'dew_point': 22.4, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.09, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 348, + 'wind_gust_speed': 18.19, + 'wind_speed': 5.31, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T11:00:00Z', + 'dew_point': 22.4, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.56, + 'temperature': 25.5, + 'uv_index': 0, + 'wind_bearing': 177, + 'wind_gust_speed': 16.79, + 'wind_speed': 4.28, + }), + dict({ + 'apparent_temperature': 27.5, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T12:00:00Z', + 'dew_point': 22.3, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.87, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 15.61, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T13:00:00Z', + 'dew_point': 22.1, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.91, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 14.7, + 'wind_speed': 4.11, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T14:00:00Z', + 'dew_point': 21.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.8, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 171, + 'wind_gust_speed': 13.81, + 'wind_speed': 4.97, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T15:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.66, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 170, + 'wind_gust_speed': 12.88, + 'wind_speed': 5.57, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T16:00:00Z', + 'dew_point': 21.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.54, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 12.0, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 24.4, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T17:00:00Z', + 'dew_point': 21.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 40.0, + 'pressure': 1014.45, + 'temperature': 22.4, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 11.43, + 'wind_speed': 5.48, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T18:00:00Z', + 'dew_point': 21.4, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 44.0, + 'pressure': 1014.45, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 11.42, + 'wind_speed': 5.38, + }), + dict({ + 'apparent_temperature': 25.0, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T19:00:00Z', + 'dew_point': 21.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 52.0, + 'pressure': 1014.63, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 12.15, + 'wind_speed': 5.39, + }), + dict({ + 'apparent_temperature': 25.6, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-14T20:00:00Z', + 'dew_point': 21.8, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 51.0, + 'pressure': 1014.91, + 'temperature': 23.4, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 13.54, + 'wind_speed': 5.45, + }), + dict({ + 'apparent_temperature': 26.6, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T21:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 42.0, + 'pressure': 1015.18, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 15.48, + 'wind_speed': 5.62, + }), + dict({ + 'apparent_temperature': 28.5, + 'cloud_coverage': 32.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T22:00:00Z', + 'dew_point': 22.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 28.999999999999996, + 'pressure': 1015.4, + 'temperature': 25.7, + 'uv_index': 1, + 'wind_bearing': 158, + 'wind_gust_speed': 17.86, + 'wind_speed': 5.84, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-14T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 77, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.54, + 'temperature': 27.2, + 'uv_index': 2, + 'wind_bearing': 155, + 'wind_gust_speed': 20.19, + 'wind_speed': 6.09, + }), + dict({ + 'apparent_temperature': 32.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T00:00:00Z', + 'dew_point': 23.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.55, + 'temperature': 28.6, + 'uv_index': 4, + 'wind_bearing': 152, + 'wind_gust_speed': 21.83, + 'wind_speed': 6.42, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-15T01:00:00Z', + 'dew_point': 23.5, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.35, + 'temperature': 29.6, + 'uv_index': 6, + 'wind_bearing': 144, + 'wind_gust_speed': 22.56, + 'wind_speed': 6.91, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T02:00:00Z', + 'dew_point': 23.5, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.0, + 'temperature': 30.4, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.83, + 'wind_speed': 7.47, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T03:00:00Z', + 'dew_point': 23.5, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.62, + 'temperature': 30.9, + 'uv_index': 7, + 'wind_bearing': 336, + 'wind_gust_speed': 22.98, + 'wind_speed': 7.95, + }), + dict({ + 'apparent_temperature': 35.4, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T04:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 31.3, + 'uv_index': 6, + 'wind_bearing': 341, + 'wind_gust_speed': 23.21, + 'wind_speed': 8.44, + }), + dict({ + 'apparent_temperature': 35.6, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T05:00:00Z', + 'dew_point': 23.7, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.95, + 'temperature': 31.5, + 'uv_index': 5, + 'wind_bearing': 344, + 'wind_gust_speed': 23.46, + 'wind_speed': 8.95, + }), + dict({ + 'apparent_temperature': 35.1, + 'cloud_coverage': 42.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T06:00:00Z', + 'dew_point': 23.6, + 'humidity': 64, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.83, + 'temperature': 31.1, + 'uv_index': 3, + 'wind_bearing': 347, + 'wind_gust_speed': 23.64, + 'wind_speed': 9.13, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T07:00:00Z', + 'dew_point': 23.4, + 'humidity': 66, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.96, + 'temperature': 30.3, + 'uv_index': 2, + 'wind_bearing': 350, + 'wind_gust_speed': 23.66, + 'wind_speed': 8.78, + }), + dict({ + 'apparent_temperature': 32.4, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T08:00:00Z', + 'dew_point': 23.1, + 'humidity': 70, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.25, + 'temperature': 29.0, + 'uv_index': 0, + 'wind_bearing': 356, + 'wind_gust_speed': 23.51, + 'wind_speed': 8.13, + }), + dict({ + 'apparent_temperature': 31.1, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T09:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.61, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 3, + 'wind_gust_speed': 23.21, + 'wind_speed': 7.48, + }), + dict({ + 'apparent_temperature': 30.0, + 'cloud_coverage': 43.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T10:00:00Z', + 'dew_point': 22.8, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.02, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 20, + 'wind_gust_speed': 22.68, + 'wind_speed': 6.83, + }), + dict({ + 'apparent_temperature': 29.2, + 'cloud_coverage': 46.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T11:00:00Z', + 'dew_point': 22.8, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.43, + 'temperature': 26.2, + 'uv_index': 0, + 'wind_bearing': 129, + 'wind_gust_speed': 22.04, + 'wind_speed': 6.1, + }), + dict({ + 'apparent_temperature': 28.4, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T12:00:00Z', + 'dew_point': 22.7, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.71, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.64, + 'wind_speed': 5.6, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T13:00:00Z', + 'dew_point': 23.2, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.52, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 16.35, + 'wind_speed': 5.58, + }), + dict({ + 'apparent_temperature': 27.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T14:00:00Z', + 'dew_point': 22.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.37, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 168, + 'wind_gust_speed': 17.11, + 'wind_speed': 5.79, + }), + dict({ + 'apparent_temperature': 26.9, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.21, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 17.32, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.4, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 16.6, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T17:00:00Z', + 'dew_point': 22.5, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.95, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 219, + 'wind_gust_speed': 15.52, + 'wind_speed': 4.62, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T18:00:00Z', + 'dew_point': 22.3, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.88, + 'temperature': 23.3, + 'uv_index': 0, + 'wind_bearing': 216, + 'wind_gust_speed': 14.64, + 'wind_speed': 4.32, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T19:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.91, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 198, + 'wind_gust_speed': 14.06, + 'wind_speed': 4.73, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T20:00:00Z', + 'dew_point': 22.4, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.99, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 189, + 'wind_gust_speed': 13.7, + 'wind_speed': 5.49, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-15T21:00:00Z', + 'dew_point': 22.5, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.07, + 'temperature': 24.4, + 'uv_index': 0, + 'wind_bearing': 183, + 'wind_gust_speed': 13.77, + 'wind_speed': 5.95, + }), + dict({ + 'apparent_temperature': 28.3, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 84, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.12, + 'temperature': 25.5, + 'uv_index': 1, + 'wind_bearing': 179, + 'wind_gust_speed': 14.38, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 29.9, + 'cloud_coverage': 52.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-15T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.13, + 'temperature': 26.9, + 'uv_index': 2, + 'wind_bearing': 170, + 'wind_gust_speed': 15.2, + 'wind_speed': 5.27, + }), + dict({ + 'apparent_temperature': 31.2, + 'cloud_coverage': 44.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T00:00:00Z', + 'dew_point': 22.9, + 'humidity': 74, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1015.04, + 'temperature': 28.0, + 'uv_index': 4, + 'wind_bearing': 155, + 'wind_gust_speed': 15.85, + 'wind_speed': 4.76, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 24.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T01:00:00Z', + 'dew_point': 22.6, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.52, + 'temperature': 29.2, + 'uv_index': 6, + 'wind_bearing': 110, + 'wind_gust_speed': 16.27, + 'wind_speed': 6.81, + }), + dict({ + 'apparent_temperature': 33.5, + 'cloud_coverage': 16.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T02:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1014.01, + 'temperature': 30.2, + 'uv_index': 8, + 'wind_bearing': 30, + 'wind_gust_speed': 16.55, + 'wind_speed': 6.86, + }), + dict({ + 'apparent_temperature': 34.2, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T03:00:00Z', + 'dew_point': 22.0, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.45, + 'temperature': 31.1, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.52, + 'wind_speed': 6.8, + }), + dict({ + 'apparent_temperature': 34.7, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T04:00:00Z', + 'dew_point': 21.9, + 'humidity': 57, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.89, + 'temperature': 31.5, + 'uv_index': 8, + 'wind_bearing': 17, + 'wind_gust_speed': 16.08, + 'wind_speed': 6.62, + }), + dict({ + 'apparent_temperature': 34.9, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T05:00:00Z', + 'dew_point': 21.9, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.39, + 'temperature': 31.8, + 'uv_index': 6, + 'wind_bearing': 20, + 'wind_gust_speed': 15.48, + 'wind_speed': 6.45, + }), + dict({ + 'apparent_temperature': 34.5, + 'cloud_coverage': 10.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T06:00:00Z', + 'dew_point': 21.7, + 'humidity': 56, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.11, + 'temperature': 31.4, + 'uv_index': 4, + 'wind_bearing': 26, + 'wind_gust_speed': 15.08, + 'wind_speed': 6.43, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 7.000000000000001, + 'condition': 'sunny', + 'datetime': '2023-09-16T07:00:00Z', + 'dew_point': 21.7, + 'humidity': 59, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.15, + 'temperature': 30.7, + 'uv_index': 2, + 'wind_bearing': 39, + 'wind_gust_speed': 14.88, + 'wind_speed': 6.61, + }), + dict({ + 'apparent_temperature': 32.5, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 63, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.41, + 'temperature': 29.6, + 'uv_index': 0, + 'wind_bearing': 72, + 'wind_gust_speed': 14.82, + 'wind_speed': 6.95, + }), + dict({ + 'apparent_temperature': 31.4, + 'cloud_coverage': 2.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T09:00:00Z', + 'dew_point': 22.1, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.75, + 'temperature': 28.5, + 'uv_index': 0, + 'wind_bearing': 116, + 'wind_gust_speed': 15.13, + 'wind_speed': 7.45, + }), + dict({ + 'apparent_temperature': 30.5, + 'cloud_coverage': 13.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T10:00:00Z', + 'dew_point': 22.3, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.13, + 'temperature': 27.6, + 'uv_index': 0, + 'wind_bearing': 140, + 'wind_gust_speed': 16.09, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 31.0, + 'condition': 'sunny', + 'datetime': '2023-09-16T11:00:00Z', + 'dew_point': 22.6, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.47, + 'temperature': 26.9, + 'uv_index': 0, + 'wind_bearing': 149, + 'wind_gust_speed': 17.37, + 'wind_speed': 8.87, + }), + dict({ + 'apparent_temperature': 29.3, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T12:00:00Z', + 'dew_point': 22.9, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.6, + 'temperature': 26.3, + 'uv_index': 0, + 'wind_bearing': 155, + 'wind_gust_speed': 18.29, + 'wind_speed': 9.21, + }), + dict({ + 'apparent_temperature': 28.7, + 'cloud_coverage': 51.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T13:00:00Z', + 'dew_point': 23.0, + 'humidity': 85, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.41, + 'temperature': 25.7, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 18.49, + 'wind_speed': 8.96, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 55.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T14:00:00Z', + 'dew_point': 22.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1013.01, + 'temperature': 25.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.47, + 'wind_speed': 8.45, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T15:00:00Z', + 'dew_point': 22.7, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.55, + 'temperature': 24.5, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 18.79, + 'wind_speed': 8.1, + }), + dict({ + 'apparent_temperature': 26.7, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T16:00:00Z', + 'dew_point': 22.6, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.1, + 'temperature': 24.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 19.81, + 'wind_speed': 8.15, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T17:00:00Z', + 'dew_point': 22.6, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.68, + 'temperature': 23.7, + 'uv_index': 0, + 'wind_bearing': 161, + 'wind_gust_speed': 20.96, + 'wind_speed': 8.3, + }), + dict({ + 'apparent_temperature': 26.0, + 'cloud_coverage': 72.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T18:00:00Z', + 'dew_point': 22.4, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 21.41, + 'wind_speed': 8.24, + }), + dict({ + 'apparent_temperature': 26.3, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T19:00:00Z', + 'dew_point': 22.5, + 'humidity': 93, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.29, + 'temperature': 23.8, + 'uv_index': 0, + 'wind_bearing': 159, + 'wind_gust_speed': 20.42, + 'wind_speed': 7.62, + }), + dict({ + 'apparent_temperature': 26.8, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-16T20:00:00Z', + 'dew_point': 22.6, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.31, + 'temperature': 24.2, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 18.61, + 'wind_speed': 6.66, + }), + dict({ + 'apparent_temperature': 27.7, + 'cloud_coverage': 57.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T21:00:00Z', + 'dew_point': 22.6, + 'humidity': 87, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 24.9, + 'uv_index': 0, + 'wind_bearing': 158, + 'wind_gust_speed': 17.14, + 'wind_speed': 5.86, + }), + dict({ + 'apparent_temperature': 28.9, + 'cloud_coverage': 48.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T22:00:00Z', + 'dew_point': 22.6, + 'humidity': 82, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.46, + 'temperature': 26.0, + 'uv_index': 1, + 'wind_bearing': 161, + 'wind_gust_speed': 16.78, + 'wind_speed': 5.5, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 39.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-16T23:00:00Z', + 'dew_point': 22.9, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.51, + 'temperature': 27.5, + 'uv_index': 2, + 'wind_bearing': 165, + 'wind_gust_speed': 17.21, + 'wind_speed': 5.56, + }), + dict({ + 'apparent_temperature': 31.7, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T00:00:00Z', + 'dew_point': 22.8, + 'humidity': 71, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.39, + 'temperature': 28.5, + 'uv_index': 4, + 'wind_bearing': 174, + 'wind_gust_speed': 17.96, + 'wind_speed': 6.04, + }), + dict({ + 'apparent_temperature': 32.6, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T01:00:00Z', + 'dew_point': 22.7, + 'humidity': 68, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.98, + 'temperature': 29.4, + 'uv_index': 6, + 'wind_bearing': 192, + 'wind_gust_speed': 19.15, + 'wind_speed': 7.23, + }), + dict({ + 'apparent_temperature': 33.6, + 'cloud_coverage': 28.999999999999996, + 'condition': 'sunny', + 'datetime': '2023-09-17T02:00:00Z', + 'dew_point': 22.8, + 'humidity': 65, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1010.38, + 'temperature': 30.1, + 'uv_index': 7, + 'wind_bearing': 225, + 'wind_gust_speed': 20.89, + 'wind_speed': 8.9, + }), + dict({ + 'apparent_temperature': 34.1, + 'cloud_coverage': 30.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T03:00:00Z', + 'dew_point': 22.8, + 'humidity': 63, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1009.75, + 'temperature': 30.7, + 'uv_index': 8, + 'wind_bearing': 264, + 'wind_gust_speed': 22.67, + 'wind_speed': 10.27, + }), + dict({ + 'apparent_temperature': 33.9, + 'cloud_coverage': 37.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T04:00:00Z', + 'dew_point': 22.5, + 'humidity': 62, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1009.18, + 'temperature': 30.5, + 'uv_index': 7, + 'wind_bearing': 293, + 'wind_gust_speed': 23.93, + 'wind_speed': 10.82, + }), + dict({ + 'apparent_temperature': 33.4, + 'cloud_coverage': 45.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T05:00:00Z', + 'dew_point': 22.4, + 'humidity': 63, + 'precipitation': 0.6, + 'precipitation_probability': 12.0, + 'pressure': 1008.71, + 'temperature': 30.1, + 'uv_index': 5, + 'wind_bearing': 308, + 'wind_gust_speed': 24.39, + 'wind_speed': 10.72, + }), + dict({ + 'apparent_temperature': 32.7, + 'cloud_coverage': 50.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T06:00:00Z', + 'dew_point': 22.2, + 'humidity': 64, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.46, + 'temperature': 29.6, + 'uv_index': 3, + 'wind_bearing': 312, + 'wind_gust_speed': 23.9, + 'wind_speed': 10.28, + }), + dict({ + 'apparent_temperature': 31.8, + 'cloud_coverage': 47.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T07:00:00Z', + 'dew_point': 22.1, + 'humidity': 67, + 'precipitation': 0.7, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1008.53, + 'temperature': 28.9, + 'uv_index': 1, + 'wind_bearing': 312, + 'wind_gust_speed': 22.3, + 'wind_speed': 9.59, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 41.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T08:00:00Z', + 'dew_point': 21.9, + 'humidity': 70, + 'precipitation': 0.6, + 'precipitation_probability': 15.0, + 'pressure': 1008.82, + 'temperature': 27.9, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 19.73, + 'wind_speed': 8.58, + }), + dict({ + 'apparent_temperature': 29.6, + 'cloud_coverage': 35.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T09:00:00Z', + 'dew_point': 22.0, + 'humidity': 74, + 'precipitation': 0.5, + 'precipitation_probability': 15.0, + 'pressure': 1009.21, + 'temperature': 27.0, + 'uv_index': 0, + 'wind_bearing': 291, + 'wind_gust_speed': 16.49, + 'wind_speed': 7.34, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 33.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T10:00:00Z', + 'dew_point': 21.9, + 'humidity': 78, + 'precipitation': 0.4, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1009.65, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 257, + 'wind_gust_speed': 12.71, + 'wind_speed': 5.91, + }), + dict({ + 'apparent_temperature': 27.8, + 'cloud_coverage': 34.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T11:00:00Z', + 'dew_point': 21.9, + 'humidity': 82, + 'precipitation': 0.3, + 'precipitation_probability': 14.000000000000002, + 'pressure': 1010.04, + 'temperature': 25.3, + 'uv_index': 0, + 'wind_bearing': 212, + 'wind_gust_speed': 9.16, + 'wind_speed': 4.54, + }), + dict({ + 'apparent_temperature': 27.1, + 'cloud_coverage': 36.0, + 'condition': 'sunny', + 'datetime': '2023-09-17T12:00:00Z', + 'dew_point': 21.9, + 'humidity': 85, + 'precipitation': 0.3, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1010.24, + 'temperature': 24.6, + 'uv_index': 0, + 'wind_bearing': 192, + 'wind_gust_speed': 7.09, + 'wind_speed': 3.62, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 40.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T13:00:00Z', + 'dew_point': 22.0, + 'humidity': 88, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1010.15, + 'temperature': 24.1, + 'uv_index': 0, + 'wind_bearing': 185, + 'wind_gust_speed': 7.2, + 'wind_speed': 3.27, + }), + dict({ + 'apparent_temperature': 25.9, + 'cloud_coverage': 44.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T14:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.3, + 'precipitation_probability': 30.0, + 'pressure': 1009.87, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.22, + }), + dict({ + 'apparent_temperature': 25.5, + 'cloud_coverage': 49.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T15:00:00Z', + 'dew_point': 21.8, + 'humidity': 92, + 'precipitation': 0.2, + 'precipitation_probability': 31.0, + 'pressure': 1009.56, + 'temperature': 23.2, + 'uv_index': 0, + 'wind_bearing': 180, + 'wind_gust_speed': 9.21, + 'wind_speed': 3.3, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 53.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T16:00:00Z', + 'dew_point': 21.8, + 'humidity': 94, + 'precipitation': 0.2, + 'precipitation_probability': 33.0, + 'pressure': 1009.29, + 'temperature': 22.9, + 'uv_index': 0, + 'wind_bearing': 182, + 'wind_gust_speed': 9.0, + 'wind_speed': 3.46, + }), + dict({ + 'apparent_temperature': 24.8, + 'cloud_coverage': 56.00000000000001, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T17:00:00Z', + 'dew_point': 21.7, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 35.0, + 'pressure': 1009.09, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 186, + 'wind_gust_speed': 8.37, + 'wind_speed': 3.72, + }), + dict({ + 'apparent_temperature': 24.6, + 'cloud_coverage': 59.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T18:00:00Z', + 'dew_point': 21.6, + 'humidity': 95, + 'precipitation': 0.0, + 'precipitation_probability': 37.0, + 'pressure': 1009.01, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 201, + 'wind_gust_speed': 7.99, + 'wind_speed': 4.07, + }), + dict({ + 'apparent_temperature': 24.9, + 'cloud_coverage': 62.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-17T19:00:00Z', + 'dew_point': 21.7, + 'humidity': 94, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.07, + 'temperature': 22.7, + 'uv_index': 0, + 'wind_bearing': 258, + 'wind_gust_speed': 8.18, + 'wind_speed': 4.55, + }), + dict({ + 'apparent_temperature': 25.2, + 'cloud_coverage': 64.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T20:00:00Z', + 'dew_point': 21.7, + 'humidity': 92, + 'precipitation': 0.0, + 'precipitation_probability': 39.0, + 'pressure': 1009.23, + 'temperature': 23.0, + 'uv_index': 0, + 'wind_bearing': 305, + 'wind_gust_speed': 8.77, + 'wind_speed': 5.17, + }), + dict({ + 'apparent_temperature': 25.8, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T21:00:00Z', + 'dew_point': 21.8, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 38.0, + 'pressure': 1009.47, + 'temperature': 23.5, + 'uv_index': 0, + 'wind_bearing': 318, + 'wind_gust_speed': 9.69, + 'wind_speed': 5.77, + }), + dict({ + 'apparent_temperature': 26.5, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-17T22:00:00Z', + 'dew_point': 21.8, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 30.0, + 'pressure': 1009.77, + 'temperature': 24.2, + 'uv_index': 1, + 'wind_bearing': 324, + 'wind_gust_speed': 10.88, + 'wind_speed': 6.26, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 80.0, + 'condition': 'rainy', + 'datetime': '2023-09-17T23:00:00Z', + 'dew_point': 21.9, + 'humidity': 83, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.09, + 'temperature': 25.1, + 'uv_index': 2, + 'wind_bearing': 329, + 'wind_gust_speed': 12.21, + 'wind_speed': 6.68, + }), + dict({ + 'apparent_temperature': 28.2, + 'cloud_coverage': 87.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T00:00:00Z', + 'dew_point': 21.9, + 'humidity': 80, + 'precipitation': 0.2, + 'precipitation_probability': 15.0, + 'pressure': 1010.33, + 'temperature': 25.7, + 'uv_index': 3, + 'wind_bearing': 332, + 'wind_gust_speed': 13.52, + 'wind_speed': 7.12, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T01:00:00Z', + 'dew_point': 21.7, + 'humidity': 72, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1007.43, + 'temperature': 27.2, + 'uv_index': 5, + 'wind_bearing': 330, + 'wind_gust_speed': 11.36, + 'wind_speed': 11.36, + }), + dict({ + 'apparent_temperature': 30.1, + 'cloud_coverage': 70.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T02:00:00Z', + 'dew_point': 21.6, + 'humidity': 70, + 'precipitation': 0.3, + 'precipitation_probability': 9.0, + 'pressure': 1007.05, + 'temperature': 27.5, + 'uv_index': 6, + 'wind_bearing': 332, + 'wind_gust_speed': 12.06, + 'wind_speed': 12.06, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 71.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T03:00:00Z', + 'dew_point': 21.6, + 'humidity': 69, + 'precipitation': 0.5, + 'precipitation_probability': 10.0, + 'pressure': 1006.67, + 'temperature': 27.8, + 'uv_index': 6, + 'wind_bearing': 333, + 'wind_gust_speed': 12.81, + 'wind_speed': 12.81, + }), + dict({ + 'apparent_temperature': 30.6, + 'cloud_coverage': 67.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T04:00:00Z', + 'dew_point': 21.5, + 'humidity': 68, + 'precipitation': 0.4, + 'precipitation_probability': 10.0, + 'pressure': 1006.28, + 'temperature': 28.0, + 'uv_index': 5, + 'wind_bearing': 335, + 'wind_gust_speed': 13.68, + 'wind_speed': 13.68, + }), + dict({ + 'apparent_temperature': 30.7, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T05:00:00Z', + 'dew_point': 21.4, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1005.89, + 'temperature': 28.1, + 'uv_index': 4, + 'wind_bearing': 336, + 'wind_gust_speed': 14.61, + 'wind_speed': 14.61, + }), + dict({ + 'apparent_temperature': 30.3, + 'cloud_coverage': 56.99999999999999, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T06:00:00Z', + 'dew_point': 21.2, + 'humidity': 67, + 'precipitation': 0.0, + 'precipitation_probability': 27.0, + 'pressure': 1005.67, + 'temperature': 27.9, + 'uv_index': 3, + 'wind_bearing': 338, + 'wind_gust_speed': 15.25, + 'wind_speed': 15.25, + }), + dict({ + 'apparent_temperature': 29.8, + 'cloud_coverage': 60.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T07:00:00Z', + 'dew_point': 21.3, + 'humidity': 69, + 'precipitation': 0.0, + 'precipitation_probability': 28.000000000000004, + 'pressure': 1005.74, + 'temperature': 27.4, + 'uv_index': 1, + 'wind_bearing': 339, + 'wind_gust_speed': 15.45, + 'wind_speed': 15.45, + }), + dict({ + 'apparent_temperature': 29.1, + 'cloud_coverage': 65.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T08:00:00Z', + 'dew_point': 21.4, + 'humidity': 73, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1005.98, + 'temperature': 26.7, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.38, + 'wind_speed': 15.38, + }), + dict({ + 'apparent_temperature': 28.6, + 'cloud_coverage': 68.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T09:00:00Z', + 'dew_point': 21.6, + 'humidity': 76, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.22, + 'temperature': 26.1, + 'uv_index': 0, + 'wind_bearing': 341, + 'wind_gust_speed': 15.27, + 'wind_speed': 15.27, + }), + dict({ + 'apparent_temperature': 27.9, + 'cloud_coverage': 66.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T10:00:00Z', + 'dew_point': 21.6, + 'humidity': 79, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1006.44, + 'temperature': 25.6, + 'uv_index': 0, + 'wind_bearing': 339, + 'wind_gust_speed': 15.09, + 'wind_speed': 15.09, + }), + dict({ + 'apparent_temperature': 27.6, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T11:00:00Z', + 'dew_point': 21.7, + 'humidity': 81, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.66, + 'temperature': 25.2, + 'uv_index': 0, + 'wind_bearing': 336, + 'wind_gust_speed': 14.88, + 'wind_speed': 14.88, + }), + dict({ + 'apparent_temperature': 27.2, + 'cloud_coverage': 61.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T12:00:00Z', + 'dew_point': 21.8, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 26.0, + 'pressure': 1006.79, + 'temperature': 24.8, + 'uv_index': 0, + 'wind_bearing': 333, + 'wind_gust_speed': 14.91, + 'wind_speed': 14.91, + }), + dict({ + 'apparent_temperature': 25.7, + 'cloud_coverage': 38.0, + 'condition': 'partlycloudy', + 'datetime': '2023-09-18T13:00:00Z', + 'dew_point': 21.2, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1012.36, + 'temperature': 23.6, + 'uv_index': 0, + 'wind_bearing': 83, + 'wind_gust_speed': 4.58, + 'wind_speed': 3.16, + }), + dict({ + 'apparent_temperature': 25.1, + 'cloud_coverage': 74.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T14:00:00Z', + 'dew_point': 21.2, + 'humidity': 89, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.96, + 'temperature': 23.1, + 'uv_index': 0, + 'wind_bearing': 144, + 'wind_gust_speed': 4.74, + 'wind_speed': 4.52, + }), + dict({ + 'apparent_temperature': 24.5, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T15:00:00Z', + 'dew_point': 20.9, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.6, + 'temperature': 22.6, + 'uv_index': 0, + 'wind_bearing': 152, + 'wind_gust_speed': 5.63, + 'wind_speed': 5.63, + }), + dict({ + 'apparent_temperature': 24.0, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T16:00:00Z', + 'dew_point': 20.7, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.37, + 'temperature': 22.3, + 'uv_index': 0, + 'wind_bearing': 156, + 'wind_gust_speed': 6.02, + 'wind_speed': 6.02, + }), + dict({ + 'apparent_temperature': 23.7, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T17:00:00Z', + 'dew_point': 20.4, + 'humidity': 91, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.2, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 6.15, + 'wind_speed': 6.15, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T18:00:00Z', + 'dew_point': 20.2, + 'humidity': 90, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.08, + 'temperature': 21.9, + 'uv_index': 0, + 'wind_bearing': 167, + 'wind_gust_speed': 6.48, + 'wind_speed': 6.48, + }), + dict({ + 'apparent_temperature': 23.2, + 'cloud_coverage': 100.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T19:00:00Z', + 'dew_point': 19.8, + 'humidity': 88, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.04, + 'temperature': 21.8, + 'uv_index': 0, + 'wind_bearing': 165, + 'wind_gust_speed': 7.51, + 'wind_speed': 7.51, + }), + dict({ + 'apparent_temperature': 23.4, + 'cloud_coverage': 99.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T20:00:00Z', + 'dew_point': 19.6, + 'humidity': 86, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.05, + 'temperature': 22.0, + 'uv_index': 0, + 'wind_bearing': 162, + 'wind_gust_speed': 8.73, + 'wind_speed': 8.73, + }), + dict({ + 'apparent_temperature': 23.9, + 'cloud_coverage': 98.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T21:00:00Z', + 'dew_point': 19.5, + 'humidity': 83, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.06, + 'temperature': 22.5, + 'uv_index': 0, + 'wind_bearing': 164, + 'wind_gust_speed': 9.21, + 'wind_speed': 9.11, + }), + dict({ + 'apparent_temperature': 25.3, + 'cloud_coverage': 96.0, + 'condition': 'cloudy', + 'datetime': '2023-09-18T22:00:00Z', + 'dew_point': 19.7, + 'humidity': 78, + 'precipitation': 0.0, + 'precipitation_probability': 0.0, + 'pressure': 1011.09, + 'temperature': 23.8, + 'uv_index': 1, + 'wind_bearing': 171, + 'wind_gust_speed': 9.03, + 'wind_speed': 7.91, + }), + ]), + }) +# --- diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py new file mode 100644 index 00000000000000..4faaac15db6145 --- /dev/null +++ b/tests/components/weatherkit/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test the Apple WeatherKit config flow.""" +from unittest.mock import AsyncMock, patch + +from apple_weatherkit import DataSetType +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientCommunicationError, + WeatherKitApiClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.weatherkit.config_flow import ( + WeatherKitUnsupportedLocationError, +) +from homeassistant.components.weatherkit.const import ( + CONF_KEY_ID, + CONF_KEY_PEM, + CONF_SERVICE_ID, + CONF_TEAM_ID, + DOMAIN, +) +from homeassistant.const import CONF_LATITUDE, CONF_LOCATION, CONF_LONGITUDE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import EXAMPLE_CONFIG_DATA + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + +EXAMPLE_USER_INPUT = { + CONF_LOCATION: { + CONF_LATITUDE: 35.4690101707532, + CONF_LONGITUDE: 135.74817234593166, + }, + CONF_KEY_ID: "QABCDEFG123", + CONF_SERVICE_ID: "io.home-assistant.testing", + CONF_TEAM_ID: "ABCD123456", + CONF_KEY_PEM: "-----BEGIN PRIVATE KEY-----\nwhateverkey\n-----END PRIVATE KEY-----", +} + + +async def _test_exception_generates_error( + hass: HomeAssistant, exception: Exception, error: str +) -> None: + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": error} + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form and create an entry.""" + 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.config_flow.WeatherKitFlowHandler._test_config", + return_value=None, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + + location = EXAMPLE_USER_INPUT[CONF_LOCATION] + assert result["title"] == f"{location[CONF_LATITUDE]}, {location[CONF_LONGITUDE]}" + + assert result["data"] == EXAMPLE_CONFIG_DATA + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize( + ("exception", "expected_error"), + [ + (WeatherKitApiClientAuthenticationError, "invalid_auth"), + (WeatherKitApiClientCommunicationError, "cannot_connect"), + (WeatherKitUnsupportedLocationError, "unsupported_location"), + (WeatherKitApiClientError, "unknown"), + ], +) +async def test_error_handling( + hass: HomeAssistant, exception: Exception, expected_error: str +) -> None: + """Test that we handle various exceptions and generate appropriate errors.""" + await _test_exception_generates_error(hass, exception, expected_error) + + +async def test_form_unsupported_location(hass: HomeAssistant) -> None: + """Test we handle when WeatherKit does not support the location.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "unsupported_location"} + + # Test that we can recover from this error by changing the location + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY diff --git a/tests/components/weatherkit/test_coordinator.py b/tests/components/weatherkit/test_coordinator.py new file mode 100644 index 00000000000000..f619ace237ae3b --- /dev/null +++ b/tests/components/weatherkit/test_coordinator.py @@ -0,0 +1,32 @@ +"""Test WeatherKit data coordinator.""" +from datetime import timedelta +from unittest.mock import patch + +from apple_weatherkit.client import WeatherKitApiClientError + +from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.core import HomeAssistant +from homeassistant.util.dt import utcnow + +from . import init_integration + +from tests.common import async_fire_time_changed + + +async def test_failed_updates(hass: HomeAssistant) -> None: + """Test that we properly handle failed updates.""" + await init_integration(hass) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ): + async_fire_time_changed( + hass, + utcnow() + timedelta(minutes=15), + ) + await hass.async_block_till_done() + + state = hass.states.get("weather.home") + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/weatherkit/test_setup.py b/tests/components/weatherkit/test_setup.py new file mode 100644 index 00000000000000..5f94d4100d543a --- /dev/null +++ b/tests/components/weatherkit/test_setup.py @@ -0,0 +1,63 @@ +"""Test the WeatherKit setup process.""" +from unittest.mock import patch + +from apple_weatherkit.client import ( + WeatherKitApiClientAuthenticationError, + WeatherKitApiClientError, +) +import pytest + +from homeassistant import config_entries +from homeassistant.components.weatherkit import async_setup_entry +from homeassistant.components.weatherkit.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from . import EXAMPLE_CONFIG_DATA + +from tests.common import MockConfigEntry + + +async def test_auth_error_handling(hass: HomeAssistant) -> None: + """Test that we handle authentication errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientAuthenticationError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientAuthenticationError, + ): + entry.add_to_hass(hass) + setup_result = await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert setup_result is False + + +async def test_client_error_handling(hass: HomeAssistant) -> None: + """Test that we handle API client errors at setup properly.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="Home", + unique_id="0123456", + data=EXAMPLE_CONFIG_DATA, + ) + + with pytest.raises(ConfigEntryNotReady), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", + side_effect=WeatherKitApiClientError, + ), patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=WeatherKitApiClientError, + ): + entry.add_to_hass(hass) + config_entries.current_entry.set(entry) + await async_setup_entry(hass, entry) + await hass.async_block_till_done() diff --git a/tests/components/weatherkit/test_weather.py b/tests/components/weatherkit/test_weather.py new file mode 100644 index 00000000000000..fabd3aab572d37 --- /dev/null +++ b/tests/components/weatherkit/test_weather.py @@ -0,0 +1,115 @@ +"""Weather entity tests for the WeatherKit integration.""" + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.weather import ( + ATTR_WEATHER_APPARENT_TEMPERATURE, + ATTR_WEATHER_CLOUD_COVERAGE, + ATTR_WEATHER_DEW_POINT, + ATTR_WEATHER_HUMIDITY, + ATTR_WEATHER_PRESSURE, + ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_UV_INDEX, + ATTR_WEATHER_VISIBILITY, + ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, + ATTR_WEATHER_WIND_SPEED, + DOMAIN as WEATHER_DOMAIN, + SERVICE_GET_FORECAST, +) +from homeassistant.components.weather.const import WeatherEntityFeature +from homeassistant.components.weatherkit.const import ATTRIBUTION +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_SUPPORTED_FEATURES +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_current_weather(hass: HomeAssistant) -> None: + """Test states of the current weather.""" + await init_integration(hass) + + state = hass.states.get("weather.home") + assert state + assert state.state == "partlycloudy" + assert state.attributes[ATTR_WEATHER_HUMIDITY] == 91 + assert state.attributes[ATTR_WEATHER_PRESSURE] == 1009.8 + assert state.attributes[ATTR_WEATHER_TEMPERATURE] == 22.9 + assert state.attributes[ATTR_WEATHER_VISIBILITY] == 20.97 + assert state.attributes[ATTR_WEATHER_WIND_BEARING] == 259 + assert state.attributes[ATTR_WEATHER_WIND_SPEED] == 5.23 + assert state.attributes[ATTR_WEATHER_APPARENT_TEMPERATURE] == 24.9 + assert state.attributes[ATTR_WEATHER_DEW_POINT] == 21.3 + assert state.attributes[ATTR_WEATHER_CLOUD_COVERAGE] == 62 + assert state.attributes[ATTR_WEATHER_WIND_GUST_SPEED] == 10.53 + assert state.attributes[ATTR_WEATHER_UV_INDEX] == 1 + assert state.attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + + +async def test_current_weather_nighttime(hass: HomeAssistant) -> None: + """Test that the condition is clear-night when it's sunny and night time.""" + await init_integration(hass, is_night_time=True) + + state = hass.states.get("weather.home") + assert state + assert state.state == "clear-night" + + +async def test_daily_forecast_missing(hass: HomeAssistant) -> None: + """Test that daily forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_daily_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_DAILY + ) == 0 + + +async def test_hourly_forecast_missing(hass: HomeAssistant) -> None: + """Test that hourly forecast is not supported when WeatherKit doesn't support it.""" + await init_integration(hass, has_hourly_forecast=False) + + state = hass.states.get("weather.home") + assert state + assert ( + state.attributes[ATTR_SUPPORTED_FEATURES] & WeatherEntityFeature.FORECAST_HOURLY + ) == 0 + + +async def test_hourly_forecast( + hass: HomeAssistant, snapshot: SnapshotAssertion +) -> None: + """Test states of the hourly forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "hourly", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot + + +async def test_daily_forecast(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None: + """Test states of the daily forecast.""" + await init_integration(hass) + + response = await hass.services.async_call( + WEATHER_DOMAIN, + SERVICE_GET_FORECAST, + { + "entity_id": "weather.home", + "type": "daily", + }, + blocking=True, + return_response=True, + ) + assert response["forecast"] != [] + assert response == snapshot From fdb9ac20c3a25e739c83b673f5f5d7b6e5644ec7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 12:08:48 -0500 Subject: [PATCH 347/640] Migrate mobile_app to use json helper (#100136) --- homeassistant/components/mobile_app/helpers.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index e8460b721a2704..e9bb3af51f280d 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -3,7 +3,6 @@ from collections.abc import Callable, Mapping from http import HTTPStatus -import json import logging from typing import Any @@ -14,7 +13,7 @@ from homeassistant.const import ATTR_DEVICE_ID, CONTENT_TYPE_JSON from homeassistant.core import Context, HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.json import JSONEncoder +from homeassistant.helpers.json import json_bytes from homeassistant.util.json import JsonValueType, json_loads from .const import ( @@ -182,7 +181,7 @@ def webhook_response( headers: Mapping[str, str] | None = None, ) -> Response: """Return a encrypted response if registration supports it.""" - data = json.dumps(data, cls=JSONEncoder) + json_data = json_bytes(data) if registration[ATTR_SUPPORTS_ENCRYPTION]: keylen, encrypt = setup_encrypt( @@ -190,17 +189,17 @@ def webhook_response( ) if ATTR_NO_LEGACY_ENCRYPTION in registration: - key = registration[CONF_SECRET] + key: bytes = registration[CONF_SECRET] else: key = registration[CONF_SECRET].encode("utf-8") key = key[:keylen] key = key.ljust(keylen, b"\0") - enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") - data = json.dumps({"encrypted": True, "encrypted_data": enc_data}) + enc_data = encrypt(json_data, key).decode("utf-8") + json_data = json_bytes({"encrypted": True, "encrypted_data": enc_data}) return Response( - text=data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers + body=json_data, status=status, content_type=CONTENT_TYPE_JSON, headers=headers ) From d5fc92eb9027df063b8c759a9e628563f66746d1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 13:34:35 -0500 Subject: [PATCH 348/640] Bump zeroconf to 0.107.0 (#100134) changelog: https://github.com/python-zeroconf/python-zeroconf/compare/0.105.0...0.107.0 --- 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 0457f7fd1c327e..8a91b14a846349 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.105.0"] + "requirements": ["zeroconf==0.107.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4d2d45de477e91..bd5fdcd9dd5384 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.105.0 +zeroconf==0.107.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 4f22107c76f7fb..1ecf291712035b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.105.0 +zeroconf==0.107.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9df6f6b1a11f8d..0180ee773ae9fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.105.0 +zeroconf==0.107.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From ad5e9e9f5b1b9e54e3c6c9c967c9b504bc6cce6d Mon Sep 17 00:00:00 2001 From: Niels Perfors Date: Mon, 11 Sep 2023 20:43:59 +0200 Subject: [PATCH 349/640] Remove code owner Verisure (#100145) --- CODEOWNERS | 4 ++-- homeassistant/components/verisure/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 29c744ce42e9a1..9771a9e25e5393 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1363,8 +1363,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velux/ @Julius2342 /homeassistant/components/venstar/ @garbled1 /tests/components/venstar/ @garbled1 -/homeassistant/components/verisure/ @frenck @niro1987 -/tests/components/verisure/ @frenck @niro1987 +/homeassistant/components/verisure/ @frenck +/tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz /homeassistant/components/version/ @ludeeus /tests/components/version/ @ludeeus diff --git a/homeassistant/components/verisure/manifest.json b/homeassistant/components/verisure/manifest.json index 7c9e7057b0cfa6..70c0505929dbf0 100644 --- a/homeassistant/components/verisure/manifest.json +++ b/homeassistant/components/verisure/manifest.json @@ -1,7 +1,7 @@ { "domain": "verisure", "name": "Verisure", - "codeowners": ["@frenck", "@niro1987"], + "codeowners": ["@frenck"], "config_flow": true, "dhcp": [ { From 5c206de9065259ca1eeaa745a42c8ff9c25769f1 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 21:06:20 +0200 Subject: [PATCH 350/640] Decouple Withings webhook tests from YAML (#100143) --- homeassistant/components/withings/common.py | 6 +- tests/components/withings/__init__.py | 85 +++-------- tests/components/withings/common.py | 9 +- tests/components/withings/conftest.py | 77 +++++----- .../withings/fixtures/get_device.json | 15 ++ .../{person0_get_meas.json => get_meas.json} | 0 ...{person0_get_sleep.json => get_sleep.json} | 0 .../withings/fixtures/notify_list.json | 22 +++ .../withings/fixtures/person0_get_device.json | 18 --- .../fixtures/person0_notify_list.json | 3 - .../components/withings/test_binary_sensor.py | 69 +++++---- tests/components/withings/test_common.py | 144 +----------------- tests/components/withings/test_config_flow.py | 2 + tests/components/withings/test_init.py | 67 +++++++- tests/components/withings/test_sensor.py | 99 ++++++------ 15 files changed, 250 insertions(+), 366 deletions(-) create mode 100644 tests/components/withings/fixtures/get_device.json rename tests/components/withings/fixtures/{person0_get_meas.json => get_meas.json} (100%) rename tests/components/withings/fixtures/{person0_get_sleep.json => get_sleep.json} (100%) create mode 100644 tests/components/withings/fixtures/notify_list.json delete mode 100644 tests/components/withings/fixtures/person0_get_device.json delete mode 100644 tests/components/withings/fixtures/person0_notify_list.json diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 76124cfff91131..516c306cc0f668 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -53,6 +53,8 @@ re.IGNORECASE, ) DATA_UPDATED_SIGNAL = "withings_entity_state_updated" +SUBSCRIBE_DELAY = datetime.timedelta(seconds=5) +UNSUBSCRIBE_DELAY = datetime.timedelta(seconds=1) class UpdateType(StrEnum): @@ -229,8 +231,8 @@ def __init__( self._user_id = user_id self._profile = profile self._webhook_config = webhook_config - self._notify_subscribe_delay = datetime.timedelta(seconds=5) - self._notify_unsubscribe_delay = datetime.timedelta(seconds=1) + self._notify_subscribe_delay = SUBSCRIBE_DELAY + self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY self._is_available = True self._cancel_interval_update_interval: CALLBACK_TYPE | None = None diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index b87188f302224e..94c7511054f99d 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -1,27 +1,21 @@ """Tests for the withings component.""" -from collections.abc import Iterable +from dataclasses import dataclass from typing import Any from urllib.parse import urlparse -import arrow -from withings_api import DateType -from withings_api.common import ( - GetSleepSummaryField, - MeasureGetMeasGroupCategory, - MeasureGetMeasResponse, - MeasureType, - NotifyAppli, - NotifyListResponse, - SleepGetSummaryResponse, - UserGetDeviceResponse, -) - from homeassistant.components.webhook import async_generate_url +from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant -from .common import WebhookResponse +from tests.common import MockConfigEntry + -from tests.common import load_json_object_fixture +@dataclass +class WebhookResponse: + """Response data from a webhook.""" + + message: str + message_code: int async def call_webhook( @@ -44,56 +38,13 @@ async def call_webhook( return WebhookResponse(message=data["message"], message_code=data["code"]) -class MockWithings: - """Mock object for Withings.""" - - def __init__( - self, - device_fixture: str = "person0_get_device.json", - measurement_fixture: str = "person0_get_meas.json", - sleep_fixture: str = "person0_get_sleep.json", - notify_list_fixture: str = "person0_notify_list.json", - ): - """Initialize mock.""" - self.device_fixture = device_fixture - self.measurement_fixture = measurement_fixture - self.sleep_fixture = sleep_fixture - self.notify_list_fixture = notify_list_fixture +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) - def user_get_device(self) -> UserGetDeviceResponse: - """Get devices.""" - fixture = load_json_object_fixture(f"withings/{self.device_fixture}") - return UserGetDeviceResponse(**fixture) - - def measure_get_meas( - self, - meastype: MeasureType | None = None, - category: MeasureGetMeasGroupCategory | None = None, - startdate: DateType | None = None, - enddate: DateType | None = None, - offset: int | None = None, - lastupdate: DateType | None = None, - ) -> MeasureGetMeasResponse: - """Get measurements.""" - fixture = load_json_object_fixture(f"withings/{self.measurement_fixture}") - return MeasureGetMeasResponse(**fixture) - - def 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.""" - fixture = load_json_object_fixture(f"withings/{self.sleep_fixture}") - return SleepGetSummaryResponse(**fixture) + await async_process_ha_core_config( + hass, + {"internal_url": "http://example.local:8123"}, + ) - def notify_list( - self, - appli: NotifyAppli | None = None, - ) -> NotifyListResponse: - """Get sleep.""" - fixture = load_json_object_fixture(f"withings/{self.notify_list_fixture}") - return NotifyListResponse(**fixture) + await hass.config_entries.async_setup(config_entry.entry_id) diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index e5c246dc95e536..6bb1b30917cf2d 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -44,6 +44,7 @@ from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry +from tests.components.withings import WebhookResponse from tests.test_util.aiohttp import AiohttpClientMocker @@ -91,14 +92,6 @@ def new_profile_config( ) -@dataclass -class WebhookResponse: - """Response data from a webhook.""" - - message: str - message_code: int - - class ComponentFactory: """Manages the setup and unloading of the withing component and profiles.""" diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index 8a85b523769ffd..fdd076e2f434fb 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -1,28 +1,30 @@ """Fixtures for tests.""" -from collections.abc import Awaitable, Callable, Coroutine +from datetime import timedelta import time -from typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock, patch 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.common import ConfigEntryWithingsApi from homeassistant.components.withings.const import DOMAIN -from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from . import MockWithings from .common import ComponentFactory -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_json_object_fixture from tests.test_util.aiohttp import AiohttpClientMocker -ComponentSetup = Callable[[], Awaitable[MockWithings]] - CLIENT_ID = "1234" CLIENT_SECRET = "5678" SCOPES = [ @@ -100,33 +102,40 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: ) -@pytest.fixture(name="setup_integration") -async def mock_setup_integration( - hass: HomeAssistant, config_entry: MockConfigEntry -) -> Callable[[], Coroutine[Any, Any, MockWithings]]: - """Fixture for setting up the component.""" - config_entry.add_to_hass(hass) +@pytest.fixture(name="withings") +def mock_withings(): + """Mock withings.""" - assert await async_setup_component(hass, "application_credentials", {}) - await async_import_client_credential( - hass, - DOMAIN, - ClientCredential(CLIENT_ID, CLIENT_SECRET), - DOMAIN, + mock = AsyncMock(spec=ConfigEntryWithingsApi) + mock.user_get_device.return_value = UserGetDeviceResponse( + **load_json_object_fixture("withings/get_device.json") ) - await async_process_ha_core_config( - hass, - {"internal_url": "http://example.local:8123"}, + mock.measure_get_meas.return_value = MeasureGetMeasResponse( + **load_json_object_fixture("withings/get_meas.json") + ) + mock.sleep_get_summary.return_value = SleepGetSummaryResponse( + **load_json_object_fixture("withings/get_sleep.json") ) + mock.notify_list.return_value = NotifyListResponse( + **load_json_object_fixture("withings/notify_list.json") + ) + + with patch( + "homeassistant.components.withings.common.ConfigEntryWithingsApi", + return_value=mock, + ): + yield mock + - async def func() -> MockWithings: - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - return mock - - return func +@pytest.fixture(name="disable_webhook_delay") +def disable_webhook_delay(): + """Disable webhook delay.""" + + mock = AsyncMock() + with patch( + "homeassistant.components.withings.common.SUBSCRIBE_DELAY", timedelta(seconds=0) + ), patch( + "homeassistant.components.withings.common.UNSUBSCRIBE_DELAY", + timedelta(seconds=0), + ): + yield mock diff --git a/tests/components/withings/fixtures/get_device.json b/tests/components/withings/fixtures/get_device.json new file mode 100644 index 00000000000000..64bac3d4a190d5 --- /dev/null +++ b/tests/components/withings/fixtures/get_device.json @@ -0,0 +1,15 @@ +{ + "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/person0_get_meas.json b/tests/components/withings/fixtures/get_meas.json similarity index 100% rename from tests/components/withings/fixtures/person0_get_meas.json rename to tests/components/withings/fixtures/get_meas.json diff --git a/tests/components/withings/fixtures/person0_get_sleep.json b/tests/components/withings/fixtures/get_sleep.json similarity index 100% rename from tests/components/withings/fixtures/person0_get_sleep.json rename to tests/components/withings/fixtures/get_sleep.json diff --git a/tests/components/withings/fixtures/notify_list.json b/tests/components/withings/fixtures/notify_list.json new file mode 100644 index 00000000000000..bc696db583a950 --- /dev/null +++ b/tests/components/withings/fixtures/notify_list.json @@ -0,0 +1,22 @@ +{ + "profiles": [ + { + "appli": 50, + "callbackurl": "https://not.my.callback/url", + "expires": 2147483647, + "comment": null + }, + { + "appli": 50, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + }, + { + "appli": 51, + "callbackurl": "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e", + "expires": 2147483647, + "comment": null + } + ] +} diff --git a/tests/components/withings/fixtures/person0_get_device.json b/tests/components/withings/fixtures/person0_get_device.json deleted file mode 100644 index 8b5e2686686ea9..00000000000000 --- a/tests/components/withings/fixtures/person0_get_device.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "status": 0, - "body": { - "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/person0_notify_list.json b/tests/components/withings/fixtures/person0_notify_list.json deleted file mode 100644 index c905c95e4cbcc7..00000000000000 --- a/tests/components/withings/fixtures/person0_notify_list.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "profiles": [] -} diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index e9eebbe356770a..6629ba5730be4d 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -1,51 +1,50 @@ """Tests for the Withings component.""" -from unittest.mock import patch +from unittest.mock import AsyncMock from withings_api.common import NotifyAppli from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import MockWithings, call_webhook -from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup +from . import call_webhook, setup_integration +from .conftest import USER_ID, WEBHOOK_ID +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator async def test_binary_sensor( hass: HomeAssistant, - setup_integration: ComponentSetup, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" - await setup_integration() - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - client = await hass_client_no_auth() - - entity_id = "binary_sensor.henk_in_bed" - - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE - - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, - client, - ) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_ON - - resp = await call_webhook( - hass, - WEBHOOK_ID, - {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, - client, - ) - assert resp.message_code == 0 - await hass.async_block_till_done() - assert hass.states.get(entity_id).state == STATE_OFF + await setup_integration(hass, config_entry) + + client = await hass_client_no_auth() + + entity_id = "binary_sensor.henk_in_bed" + + assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_IN}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_ON + + resp = await call_webhook( + hass, + WEBHOOK_ID, + {"userid": USER_ID, "appli": NotifyAppli.BED_OUT}, + client, + ) + assert resp.message_code == 0 + await hass.async_block_till_done() + assert hass.states.get(entity_id).state == STATE_OFF diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py index 91915a47920af5..80f5700d64cfd1 100644 --- a/tests/components/withings/test_common.py +++ b/tests/components/withings/test_common.py @@ -1,5 +1,4 @@ """Tests for the Withings component.""" -import datetime from http import HTTPStatus import re from typing import Any @@ -9,20 +8,15 @@ from aiohttp.test_utils import TestClient import pytest import requests_mock -from withings_api.common import NotifyAppli, NotifyListProfile, NotifyListResponse +from withings_api.common import NotifyAppli -from homeassistant.components.withings.common import ( - ConfigEntryWithingsApi, - DataManager, - WebhookConfig, -) +from homeassistant.components.withings.common import ConfigEntryWithingsApi from homeassistant.core import HomeAssistant from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config from tests.common import MockConfigEntry -from tests.test_util.aiohttp import AiohttpClientMocker from tests.typing import ClientSessionGenerator @@ -101,137 +95,3 @@ async def test_webhook_post( resp.close() assert data["code"] == expected_code - - -async def test_webhook_head( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test head method on webhook view.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.head(urlparse(data_manager.webhook_config.url).path) - assert resp.status == HTTPStatus.OK - - -async def test_webhook_put( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - resp = await client.put(urlparse(data_manager.webhook_config.url).path) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - assert resp.status == HTTPStatus.OK - data = await resp.json() - assert data - assert data["code"] == 2 - - -async def test_data_manager_webhook_subscription( - hass: HomeAssistant, - component_factory: ComponentFactory, - aioclient_mock: AiohttpClientMocker, -) -> None: - """Test data manager webhook subscriptions.""" - person0 = new_profile_config("person0", 0) - await component_factory.configure_component(profile_configs=(person0,)) - - api: ConfigEntryWithingsApi = MagicMock(spec=ConfigEntryWithingsApi) - data_manager = DataManager( - hass, - "person0", - api, - 0, - WebhookConfig(id="1234", url="http://localhost/api/webhook/1234", enabled=True), - ) - - data_manager._notify_subscribe_delay = datetime.timedelta(seconds=0) - data_manager._notify_unsubscribe_delay = datetime.timedelta(seconds=0) - - api.notify_list.return_value = NotifyListResponse( - profiles=( - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl="https://not.my.callback/url", - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_IN, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - NotifyListProfile( - appli=NotifyAppli.BED_OUT, - callbackurl=data_manager.webhook_config.url, - expires=None, - comment=None, - ), - ) - ) - - aioclient_mock.clear_requests() - aioclient_mock.request( - "HEAD", - data_manager.webhook_config.url, - status=HTTPStatus.OK, - ) - - # Test subscribing - await data_manager.async_subscribe_webhook() - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.WEIGHT - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.CIRCULATORY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.ACTIVITY - ) - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.SLEEP - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.USER - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - - with pytest.raises(AssertionError): - api.notify_subscribe.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) - - # Test unsubscribing. - await data_manager.async_unsubscribe_webhook() - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_IN - ) - api.notify_revoke.assert_any_call( - data_manager.webhook_config.url, NotifyAppli.BED_OUT - ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 51403e672252f1..360766e028653e 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -86,6 +86,7 @@ async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, + disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" @@ -154,6 +155,7 @@ async def test_config_reauth_profile( hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, + disable_webhook_delay, current_request_with_host, ) -> None: """Test reauth an existing profile re-creates the config entry.""" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 9ccc53d0b88330..acd21886e781c6 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,11 +1,14 @@ """Tests for the Withings component.""" -from unittest.mock import MagicMock, patch +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch +from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import UnauthorizedException +from withings_api.common import NotifyAppli, UnauthorizedException import homeassistant.components.webhook as webhook +from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager from homeassistant.config import async_process_ha_core_config @@ -19,10 +22,14 @@ from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from . import setup_integration from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from .conftest import WEBHOOK_ID -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import ClientSessionGenerator def config_schema_validate(withings_config) -> dict: @@ -224,3 +231,57 @@ async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert config_entry.unique_id == "1234" + + +async def test_data_manager_webhook_subscription( + hass: HomeAssistant, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, +) -> None: + """Test data manager webhook subscriptions.""" + await setup_integration(hass, 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() + + assert withings.notify_subscribe.call_count == 4 + + webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" + + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.CIRCULATORY) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + + withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + + +@pytest.mark.parametrize( + "method", + [ + "PUT", + "HEAD", + ], +) +async def test_requests( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + method: str, + disable_webhook_delay, +) -> None: + """Test we handle request methods Withings sends.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + response = await client.request( + method=method, + path=urlparse(webhook_url).path, + ) + assert response.status == 200 diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 6ab0fc97f4e91a..4cc71df80d791f 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 typing import Any -from unittest.mock import patch +from unittest.mock import AsyncMock import pytest from syrupy import SnapshotAssertion @@ -14,10 +14,11 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import EntityRegistry -from . import MockWithings, call_webhook +from . import call_webhook, setup_integration from .common import async_get_entity_id -from .conftest import USER_ID, WEBHOOK_ID, ComponentSetup +from .conftest import USER_ID, WEBHOOK_ID +from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator WITHINGS_MEASUREMENTS_MAP: dict[Measurement, WithingsEntityDescription] = { @@ -77,65 +78,55 @@ def async_assert_state_equals( @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_sensor_default_enabled_entities( hass: HomeAssistant, - setup_integration: ComponentSetup, + withings: AsyncMock, + config_entry: MockConfigEntry, + disable_webhook_delay, hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test entities enabled by default.""" - await setup_integration() + await setup_integration(hass, config_entry) entity_registry: EntityRegistry = er.async_get(hass) - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - 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 - - 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) + 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, setup_integration: ComponentSetup, snapshot: SnapshotAssertion + hass: HomeAssistant, + snapshot: SnapshotAssertion, + withings: AsyncMock, + disable_webhook_delay, + config_entry: MockConfigEntry, ) -> None: """Test all entities.""" - await setup_integration() - - mock = MockWithings() - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - return_value=mock, - ): - for sensor in SENSORS: - entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) - assert hass.states.get(entity_id) == snapshot + await setup_integration(hass, config_entry) + + for sensor in SENSORS: + entity_id = await async_get_entity_id(hass, sensor, USER_ID, SENSOR_DOMAIN) + assert hass.states.get(entity_id) == snapshot From cbb28b69436ba72256d43921a5d0faa554146456 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 11 Sep 2023 21:39:33 +0200 Subject: [PATCH 351/640] Migrate internal ZHA data to a dataclasses (#100127) * Cache device triggers on startup * reorg zha init * don't reuse gateway * don't nuke yaml configuration * review comments * Add unit tests * Do not cache device and entity registries * [WIP] Wrap ZHA data in a dataclass * [WIP] Get unit tests passing * Use a helper function for getting the gateway object to fix annotations * Remove `bridge_id` * Fix typing issues with entity references in group websocket info * Use `Platform` instead of `str` for entity platform matching * Use `get_zha_gateway` in a few more places * Fix flaky unit test * Use `slots` for ZHA data Co-authored-by: J. Nick Koston --------- Co-authored-by: David F. Mulcahey Co-authored-by: J. Nick Koston --- homeassistant/components/zha/__init__.py | 42 ++++------- .../components/zha/alarm_control_panel.py | 6 +- homeassistant/components/zha/api.py | 29 ++------ homeassistant/components/zha/backup.py | 5 +- homeassistant/components/zha/binary_sensor.py | 5 +- homeassistant/components/zha/button.py | 6 +- homeassistant/components/zha/climate.py | 5 +- homeassistant/components/zha/core/const.py | 1 - homeassistant/components/zha/core/device.py | 8 +- .../components/zha/core/discovery.py | 41 ++++++----- homeassistant/components/zha/core/endpoint.py | 6 +- homeassistant/components/zha/core/gateway.py | 68 +++++++++-------- homeassistant/components/zha/core/group.py | 45 ++++++++---- homeassistant/components/zha/core/helpers.py | 43 ++++++++--- .../components/zha/core/registries.py | 28 ++++--- homeassistant/components/zha/cover.py | 5 +- .../components/zha/device_tracker.py | 5 +- .../components/zha/device_trigger.py | 8 +- homeassistant/components/zha/diagnostics.py | 16 ++-- homeassistant/components/zha/entity.py | 9 ++- homeassistant/components/zha/fan.py | 11 +-- homeassistant/components/zha/light.py | 6 +- homeassistant/components/zha/lock.py | 5 +- homeassistant/components/zha/number.py | 5 +- homeassistant/components/zha/radio_manager.py | 5 +- homeassistant/components/zha/select.py | 5 +- homeassistant/components/zha/sensor.py | 5 +- homeassistant/components/zha/siren.py | 5 +- homeassistant/components/zha/switch.py | 5 +- homeassistant/components/zha/websocket_api.py | 73 +++++++++---------- tests/components/zha/common.py | 10 +-- tests/components/zha/conftest.py | 9 ++- tests/components/zha/test_api.py | 5 +- tests/components/zha/test_cluster_handlers.py | 3 +- tests/components/zha/test_device_action.py | 34 ++++----- tests/components/zha/test_device_trigger.py | 1 + tests/components/zha/test_diagnostics.py | 4 +- tests/components/zha/test_discover.py | 2 +- tests/components/zha/test_fan.py | 2 +- tests/components/zha/test_gateway.py | 3 +- tests/components/zha/test_light.py | 15 ++-- .../zha/test_silabs_multiprotocol.py | 5 +- tests/components/zha/test_switch.py | 2 +- tests/components/zha/test_websocket_api.py | 4 +- 44 files changed, 317 insertions(+), 288 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 662ddd080e0367..bd181d82a33005 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -33,9 +33,6 @@ CONF_USB_PATH, CONF_ZIGPY, DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_DEVICE_TRIGGER_CACHE, - DATA_ZHA_GATEWAY, DOMAIN, PLATFORMS, SIGNAL_ADD_ENTITIES, @@ -43,6 +40,7 @@ ) 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 DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({vol.Optional(CONF_TYPE): cv.string}) @@ -81,11 +79,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up ZHA from config.""" - hass.data[DATA_ZHA] = {} - - if DOMAIN in config: - conf = config[DOMAIN] - hass.data[DATA_ZHA][DATA_ZHA_CONFIG] = conf + zha_data = ZHAData() + zha_data.yaml_config = config.get(DOMAIN, {}) + hass.data[DATA_ZHA] = zha_data return True @@ -120,14 +116,12 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b data[CONF_DEVICE][CONF_DEVICE_PATH] = cleaned_path hass.config_entries.async_update_entry(config_entry, data=data) - zha_data = hass.data.setdefault(DATA_ZHA, {}) - config = zha_data.get(DATA_ZHA_CONFIG, {}) - - for platform in PLATFORMS: - zha_data.setdefault(platform, []) + zha_data = get_zha_data(hass) - if config.get(CONF_ENABLE_QUIRKS, True): - setup_quirks(custom_quirks_path=config.get(CONF_CUSTOM_QUIRKS_PATH)) + if zha_data.yaml_config.get(CONF_ENABLE_QUIRKS, True): + setup_quirks( + custom_quirks_path=zha_data.yaml_config.get(CONF_CUSTOM_QUIRKS_PATH) + ) # temporary code to remove the ZHA storage file from disk. # this will be removed in 2022.10.0 @@ -139,8 +133,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b _LOGGER.debug("ZHA storage file does not exist or was already removed") # Load and cache device trigger information early - zha_data.setdefault(DATA_ZHA_DEVICE_TRIGGER_CACHE, {}) - device_registry = dr.async_get(hass) radio_mgr = ZhaRadioManager.from_config_entry(hass, config_entry) @@ -154,14 +146,14 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b if dev_entry is None: continue - zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE][dev_entry.id] = ( + zha_data.device_trigger_cache[dev_entry.id] = ( str(dev.ieee), get_device_automation_triggers(dev), ) - _LOGGER.debug("Trigger cache: %s", zha_data[DATA_ZHA_DEVICE_TRIGGER_CACHE]) + _LOGGER.debug("Trigger cache: %s", zha_data.device_trigger_cache) - zha_gateway = ZHAGateway(hass, config, config_entry) + zha_gateway = ZHAGateway(hass, zha_data.yaml_config, config_entry) async def async_zha_shutdown(): """Handle shutdown tasks.""" @@ -172,7 +164,7 @@ async def async_zha_shutdown(): # be in when we get here in failure cases with contextlib.suppress(KeyError): for platform in PLATFORMS: - del hass.data[DATA_ZHA][platform] + del zha_data.platforms[platform] config_entry.async_on_unload(async_zha_shutdown) @@ -212,10 +204,8 @@ async def async_zha_shutdown(): async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload ZHA config entry.""" - try: - del hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - except KeyError: - return False + zha_data = get_zha_data(hass) + zha_data.gateway = None GROUP_PROBE.cleanup() websocket_api.async_unload_api(hass) @@ -241,7 +231,7 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> CONF_DEVICE: {CONF_DEVICE_PATH: config_entry.data[CONF_USB_PATH]}, } - baudrate = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}).get(CONF_BAUDRATE) + baudrate = get_zha_data(hass).yaml_config.get(CONF_BAUDRATE) if data[CONF_RADIO_TYPE] != RadioType.deconz and baudrate in BAUD_RATES: data[CONF_DEVICE][CONF_BAUDRATE] = baudrate diff --git a/homeassistant/components/zha/alarm_control_panel.py b/homeassistant/components/zha/alarm_control_panel.py index b6794e909d8c2b..21cacfa5dd42bf 100644 --- a/homeassistant/components/zha/alarm_control_panel.py +++ b/homeassistant/components/zha/alarm_control_panel.py @@ -35,11 +35,10 @@ CONF_ALARM_ARM_REQUIRES_CODE, CONF_ALARM_FAILED_TRIES, CONF_ALARM_MASTER_CODE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, ZHA_ALARM_OPTIONS, ) -from .core.helpers import async_get_zha_config_value +from .core.helpers import async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +64,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation alarm control panel from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.ALARM_CONTROL_PANEL] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.ALARM_CONTROL_PANEL] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 3d44103e225ead..f63fb9d09de78a 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -9,33 +9,22 @@ from zigpy.types import Channels from zigpy.util import pick_optimal_channel -from .core.const import ( - CONF_RADIO_TYPE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, - DOMAIN, - RadioType, -) +from .core.const import CONF_RADIO_TYPE, DOMAIN, RadioType from .core.gateway import ZHAGateway +from .core.helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -def _get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Get a reference to the ZHA gateway device.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: """Find the singleton ZHA config entry, if one exists.""" # If ZHA is already running, use its config entry try: - zha_gateway = _get_gateway(hass) - except KeyError: + zha_gateway = get_zha_gateway(hass) + except ValueError: pass else: return zha_gateway.config_entry @@ -51,8 +40,7 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry: def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup: """Get the network settings for the currently active ZHA network.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller return NetworkBackup( node_info=app.state.node_info, @@ -67,7 +55,7 @@ async def async_get_last_network_settings( if config_entry is None: config_entry = _get_config_entry(hass) - config = hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(hass).yaml_config zha_gateway = ZHAGateway(hass, config, config_entry) app_controller_cls, app_config = zha_gateway.get_application_controller_data() @@ -91,7 +79,7 @@ async def async_get_network_settings( try: return async_get_active_network_settings(hass) - except KeyError: + except ValueError: return await async_get_last_network_settings(hass, config_entry) @@ -120,8 +108,7 @@ async def async_change_channel( ) -> None: """Migrate the ZHA network to a new channel.""" - zha_gateway: ZHAGateway = _get_gateway(hass) - app = zha_gateway.application_controller + app = get_zha_gateway(hass).application_controller if new_channel == "auto": channel_energy = await app.energy_scan( diff --git a/homeassistant/components/zha/backup.py b/homeassistant/components/zha/backup.py index 89d5294e1c475b..e125a8085f6d1a 100644 --- a/homeassistant/components/zha/backup.py +++ b/homeassistant/components/zha/backup.py @@ -3,8 +3,7 @@ from homeassistant.core import HomeAssistant -from .core import ZHAGateway -from .core.const import DATA_ZHA, DATA_ZHA_GATEWAY +from .core.helpers import get_zha_gateway _LOGGER = logging.getLogger(__name__) @@ -13,7 +12,7 @@ async def async_pre_backup(hass: HomeAssistant) -> None: """Perform operations before a backup starts.""" _LOGGER.debug("Performing coordinator backup") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) await zha_gateway.application_controller.backups.create_backup(load_devices=True) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 50cfb7833702e4..c32bd5eeb67a74 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -26,10 +26,10 @@ CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ZONE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -65,7 +65,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation binary sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BINARY_SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BINARY_SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/button.py b/homeassistant/components/zha/button.py index 7a4132115b81e3..4114a3dea7cae9 100644 --- a/homeassistant/components/zha/button.py +++ b/homeassistant/components/zha/button.py @@ -14,7 +14,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from .core import discovery -from .core.const import CLUSTER_HANDLER_IDENTIFY, DATA_ZHA, SIGNAL_ADD_ENTITIES +from .core.const import CLUSTER_HANDLER_IDENTIFY, SIGNAL_ADD_ENTITIES +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -38,7 +39,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation button from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.BUTTON] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.BUTTON] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/climate.py b/homeassistant/components/zha/climate.py index cf868ef8b7b888..5cbe2684ab418d 100644 --- a/homeassistant/components/zha/climate.py +++ b/homeassistant/components/zha/climate.py @@ -45,13 +45,13 @@ from .core.const import ( CLUSTER_HANDLER_FAN, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, PRESET_COMPLEX, PRESET_SCHEDULE, PRESET_TEMP_MANUAL, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -115,7 +115,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.CLIMATE] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.CLIMATE] unsub = async_dispatcher_connect( hass, SIGNAL_ADD_ENTITIES, diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 9569fc4965968c..b37fa7ffe6db92 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -184,7 +184,6 @@ DATA_DEVICE_CONFIG = "zha_device_config" DATA_ZHA = "zha" DATA_ZHA_CONFIG = "config" -DATA_ZHA_BRIDGE_ID = "zha_bridge_id" DATA_ZHA_CORE_EVENTS = "zha_core_events" DATA_ZHA_DEVICE_TRIGGER_CACHE = "zha_device_trigger_cache" DATA_ZHA_GATEWAY = "zha_gateway" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 60bf78e516c227..8f5b087f068af5 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -25,6 +25,7 @@ from homeassistant.const import ATTR_COMMAND, ATTR_DEVICE_ID, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -420,7 +421,9 @@ def async_update_sw_build_id(self, sw_version: int) -> None: """Update device sw version.""" if self.device_id is None: return - self._zha_gateway.ha_device_registry.async_update_device( + + device_registry = dr.async_get(self.hass) + device_registry.async_update_device( self.device_id, sw_version=f"0x{sw_version:08x}" ) @@ -658,7 +661,8 @@ def zha_device_info(self) -> dict[str, Any]: ) device_info[ATTR_ENDPOINT_NAMES] = names - reg_device = self.gateway.ha_device_registry.async_get(self.device_id) + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(self.device_id) if reg_device is not None: device_info["user_given_name"] = reg_device.name_by_user device_info["device_reg_id"] = reg_device.id diff --git a/homeassistant/components/zha/core/discovery.py b/homeassistant/components/zha/core/discovery.py index 92b68bdb159218..a56e7044d3a61c 100644 --- a/homeassistant/components/zha/core/discovery.py +++ b/homeassistant/components/zha/core/discovery.py @@ -4,10 +4,11 @@ from collections import Counter from collections.abc import Callable import logging -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from homeassistant.const import CONF_TYPE, Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send, @@ -49,12 +50,12 @@ security, smartenergy, ) +from .helpers import get_zha_data, get_zha_gateway if TYPE_CHECKING: from ..entity import ZhaEntity from .device import ZHADevice from .endpoint import Endpoint - from .gateway import ZHAGateway from .group import ZHAGroup _LOGGER = logging.getLogger(__name__) @@ -113,6 +114,8 @@ def discover_by_device_type(self, endpoint: Endpoint) -> None: platform = zha_regs.DEVICE_CLASS[ep_profile_id].get(ep_device_type) if platform and platform in zha_const.PLATFORMS: + platform = cast(Platform, platform) + cluster_handlers = endpoint.unclaimed_cluster_handlers() platform_entity_class, claimed = zha_regs.ZHA_ENTITIES.get_entity( platform, @@ -263,9 +266,7 @@ def discover_multi_entities( def initialize(self, hass: HomeAssistant) -> None: """Update device overrides config.""" - zha_config: ConfigType = hass.data[zha_const.DATA_ZHA].get( - zha_const.DATA_ZHA_CONFIG, {} - ) + zha_config = get_zha_data(hass).yaml_config if overrides := zha_config.get(zha_const.CONF_DEVICE_CONFIG): self._device_configs.update(overrides) @@ -297,9 +298,7 @@ def cleanup(self) -> None: @callback def _reprobe_group(self, group_id: int) -> None: """Reprobe a group for entities after its members change.""" - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_gateway = get_zha_gateway(self._hass) if (zha_group := zha_gateway.groups.get(group_id)) is None: return self.discover_group_entities(zha_group) @@ -321,14 +320,14 @@ def discover_group_entities(self, group: ZHAGroup) -> None: if not entity_domains: return - zha_gateway: ZHAGateway = self._hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] + zha_data = get_zha_data(self._hass) + zha_gateway = get_zha_gateway(self._hass) + for domain in entity_domains: entity_class = zha_regs.ZHA_ENTITIES.get_group_entity(domain) if entity_class is None: continue - self._hass.data[zha_const.DATA_ZHA][domain].append( + zha_data.platforms[domain].append( ( entity_class, ( @@ -342,24 +341,26 @@ def discover_group_entities(self, group: ZHAGroup) -> None: async_dispatcher_send(self._hass, zha_const.SIGNAL_ADD_ENTITIES) @staticmethod - def determine_entity_domains(hass: HomeAssistant, group: ZHAGroup) -> list[str]: + def determine_entity_domains( + hass: HomeAssistant, group: ZHAGroup + ) -> list[Platform]: """Determine the entity domains for this group.""" - entity_domains: list[str] = [] - zha_gateway: ZHAGateway = hass.data[zha_const.DATA_ZHA][ - zha_const.DATA_ZHA_GATEWAY - ] - all_domain_occurrences = [] + entity_registry = er.async_get(hass) + + entity_domains: list[Platform] = [] + all_domain_occurrences: list[Platform] = [] + for member in group.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) all_domain_occurrences.extend( [ - entity.domain + cast(Platform, entity.domain) for entity in entities if entity.domain in zha_regs.GROUP_ENTITY_DOMAINS ] diff --git a/homeassistant/components/zha/core/endpoint.py b/homeassistant/components/zha/core/endpoint.py index bdef5ac46af7ed..c87ee60d6b30d6 100644 --- a/homeassistant/components/zha/core/endpoint.py +++ b/homeassistant/components/zha/core/endpoint.py @@ -16,6 +16,7 @@ from . import const, discovery, registries from .cluster_handlers import ClusterHandler from .cluster_handlers.general import MultistateInput +from .helpers import get_zha_data if TYPE_CHECKING: from .cluster_handlers import ClientClusterHandler @@ -195,7 +196,7 @@ async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None: def async_new_entity( self, - platform: Platform | str, + platform: Platform, entity_class: CALLABLE_T, unique_id: str, cluster_handlers: list[ClusterHandler], @@ -206,7 +207,8 @@ def async_new_entity( if self.device.status == DeviceStatus.INITIALIZED: return - self.device.hass.data[const.DATA_ZHA][platform].append( + zha_data = get_zha_data(self.device.hass) + zha_data.platforms[platform].append( (entity_class, (unique_id, self.device, cluster_handlers)) ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5cc2cd9a4b9216..5fe84005d7a833 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -46,9 +46,6 @@ CONF_RADIO_TYPE, CONF_USE_THREAD, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, - DATA_ZHA_GATEWAY, DEBUG_COMP_BELLOWS, DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, @@ -87,6 +84,7 @@ ) from .device import DeviceStatus, ZHADevice from .group import GroupMember, ZHAGroup +from .helpers import get_zha_data from .registries import GROUP_ENTITY_DOMAINS if TYPE_CHECKING: @@ -123,8 +121,6 @@ class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" # -- Set in async_initialize -- - ha_device_registry: dr.DeviceRegistry - ha_entity_registry: er.EntityRegistry application_controller: ControllerApplication radio_description: str @@ -132,7 +128,7 @@ def __init__( self, hass: HomeAssistant, config: ConfigType, config_entry: ConfigEntry ) -> None: """Initialize the gateway.""" - self._hass = hass + self.hass = hass self._config = config self._devices: dict[EUI64, ZHADevice] = {} self._groups: dict[int, ZHAGroup] = {} @@ -159,7 +155,7 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: app_config = self._config.get(CONF_ZIGPY, {}) database = self._config.get( CONF_DATABASE, - self._hass.config.path(DEFAULT_DATABASE_NAME), + self.hass.config.path(DEFAULT_DATABASE_NAME), ) app_config[CONF_DATABASE] = database app_config[CONF_DEVICE] = self.config_entry.data[CONF_DEVICE] @@ -191,11 +187,8 @@ def get_application_controller_data(self) -> tuple[ControllerApplication, dict]: async def async_initialize(self) -> None: """Initialize controller and connect radio.""" - discovery.PROBE.initialize(self._hass) - discovery.GROUP_PROBE.initialize(self._hass) - - self.ha_device_registry = dr.async_get(self._hass) - self.ha_entity_registry = er.async_get(self._hass) + discovery.PROBE.initialize(self.hass) + discovery.GROUP_PROBE.initialize(self.hass) app_controller_cls, app_config = self.get_application_controller_data() self.application_controller = await app_controller_cls.new( @@ -225,8 +218,8 @@ async def async_initialize(self) -> None: else: break - self._hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self - self._hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID] = str(self.coordinator_ieee) + zha_data = get_zha_data(self.hass) + zha_data.gateway = self self.coordinator_zha_device = self._async_get_or_create_device( self._find_coordinator_device(), restored=True @@ -301,7 +294,7 @@ async def fetch_updated_state() -> None: # background the fetching of state for mains powered devices self.config_entry.async_create_background_task( - self._hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" + self.hass, fetch_updated_state(), "zha.gateway-fetch_updated_state" ) def device_joined(self, device: zigpy.device.Device) -> None: @@ -311,7 +304,7 @@ def device_joined(self, device: zigpy.device.Device) -> None: address """ async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_JOINED, @@ -327,7 +320,7 @@ def raw_device_initialized(self, device: zigpy.device.Device) -> None: """Handle a device initialization without quirks loaded.""" manuf = device.manufacturer async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_RAW_INIT, @@ -344,7 +337,7 @@ def raw_device_initialized(self, device: zigpy.device.Device) -> None: def device_initialized(self, device: zigpy.device.Device) -> None: """Handle device joined and basic information discovered.""" - self._hass.async_create_task(self.async_device_initialized(device)) + self.hass.async_create_task(self.async_device_initialized(device)) def device_left(self, device: zigpy.device.Device) -> None: """Handle device leaving the network.""" @@ -359,7 +352,7 @@ def group_member_removed( zha_group.info("group_member_removed - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_REMOVED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) def group_member_added( @@ -371,7 +364,7 @@ def group_member_added( zha_group.info("group_member_added - endpoint: %s", endpoint) self._send_group_gateway_message(zigpy_group, ZHA_GW_MSG_GROUP_MEMBER_ADDED) async_dispatcher_send( - self._hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" + self.hass, f"{SIGNAL_GROUP_MEMBERSHIP_CHANGE}_0x{zigpy_group.group_id:04x}" ) if len(zha_group.members) == 2: # we need to do this because there wasn't already @@ -399,7 +392,7 @@ def _send_group_gateway_message( zha_group = self._groups.get(zigpy_group.group_id) if zha_group is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: gateway_message_type, @@ -416,9 +409,11 @@ async def _async_remove_device( remove_tasks.append(entity_ref.remove_future) if remove_tasks: await asyncio.wait(remove_tasks) - reg_device = self.ha_device_registry.async_get(device.device_id) + + device_registry = dr.async_get(self.hass) + reg_device = device_registry.async_get(device.device_id) if reg_device is not None: - self.ha_device_registry.async_remove_device(reg_device.id) + device_registry.async_remove_device(reg_device.id) def device_removed(self, device: zigpy.device.Device) -> None: """Handle device being removed from the network.""" @@ -427,14 +422,14 @@ def device_removed(self, device: zigpy.device.Device) -> None: if zha_device is not None: device_info = zha_device.zha_device_info zha_device.async_cleanup_handles() - async_dispatcher_send(self._hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") - self._hass.async_create_task( + async_dispatcher_send(self.hass, f"{SIGNAL_REMOVE}_{str(zha_device.ieee)}") + self.hass.async_create_task( self._async_remove_device(zha_device, entity_refs), "ZHAGateway._async_remove_device", ) if device_info is not None: async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_REMOVED, @@ -488,9 +483,10 @@ def _cleanup_group_entity_registry_entries( ] # then we get all group entity entries tied to the coordinator + entity_registry = er.async_get(self.hass) assert self.coordinator_zha_device all_group_entity_entries = er.async_entries_for_device( - self.ha_entity_registry, + entity_registry, self.coordinator_zha_device.device_id, include_disabled_entities=True, ) @@ -508,7 +504,7 @@ def _cleanup_group_entity_registry_entries( _LOGGER.debug( "cleaning up entity registry entry for entity: %s", entry.entity_id ) - self.ha_entity_registry.async_remove(entry.entity_id) + entity_registry.async_remove(entry.entity_id) @property def coordinator_ieee(self) -> EUI64: @@ -582,9 +578,11 @@ def _async_get_or_create_device( ) -> ZHADevice: """Get or create a ZHA device.""" if (zha_device := self._devices.get(zigpy_device.ieee)) is None: - zha_device = ZHADevice.new(self._hass, zigpy_device, self, restored) + zha_device = ZHADevice.new(self.hass, zigpy_device, self, restored) self._devices[zigpy_device.ieee] = zha_device - device_registry_device = self.ha_device_registry.async_get_or_create( + + device_registry = dr.async_get(self.hass) + device_registry_device = device_registry.async_get_or_create( config_entry_id=self.config_entry.entry_id, connections={(dr.CONNECTION_ZIGBEE, str(zha_device.ieee))}, identifiers={(DOMAIN, str(zha_device.ieee))}, @@ -600,7 +598,7 @@ def _async_get_or_create_group(self, zigpy_group: zigpy.group.Group) -> ZHAGroup """Get or create a ZHA group.""" zha_group = self._groups.get(zigpy_group.group_id) if zha_group is None: - zha_group = ZHAGroup(self._hass, self, zigpy_group) + zha_group = ZHAGroup(self.hass, self, zigpy_group) self._groups[zigpy_group.group_id] = zha_group return zha_group @@ -645,7 +643,7 @@ async def async_device_initialized(self, device: zigpy.device.Device) -> None: device_info = zha_device.zha_device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.INITIALIZED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -659,7 +657,7 @@ async def _async_device_joined(self, zha_device: ZHADevice) -> None: await zha_device.async_configure() device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, @@ -667,7 +665,7 @@ async def _async_device_joined(self, zha_device: ZHADevice) -> None: }, ) await zha_device.async_initialize(from_cache=False) - async_dispatcher_send(self._hass, SIGNAL_ADD_ENTITIES) + async_dispatcher_send(self.hass, SIGNAL_ADD_ENTITIES) async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: _LOGGER.debug( @@ -681,7 +679,7 @@ async def _async_device_rejoined(self, zha_device: ZHADevice) -> None: device_info = zha_device.device_info device_info[DEVICE_PAIRING_STATUS] = DevicePairingStatus.CONFIGURED.name async_dispatcher_send( - self._hass, + self.hass, ZHA_GW_MSG, { ATTR_TYPE: ZHA_GW_MSG_DEVICE_FULL_INIT, diff --git a/homeassistant/components/zha/core/group.py b/homeassistant/components/zha/core/group.py index ebea2f4ac4123d..519668052e0ede 100644 --- a/homeassistant/components/zha/core/group.py +++ b/homeassistant/components/zha/core/group.py @@ -11,6 +11,7 @@ from zigpy.types.named import EUI64 from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import async_entries_for_device from .helpers import LogMixin @@ -32,8 +33,8 @@ class GroupMember(NamedTuple): class GroupEntityReference(NamedTuple): """Reference to a group entity.""" - name: str - original_name: str + name: str | None + original_name: str | None entity_id: int @@ -80,20 +81,30 @@ def member_info(self) -> dict[str, Any]: @property def associated_entities(self) -> list[dict[str, Any]]: """Return the list of entities that were derived from this endpoint.""" - ha_entity_registry = self.device.gateway.ha_entity_registry + entity_registry = er.async_get(self._zha_device.hass) zha_device_registry = self.device.gateway.device_registry - return [ - GroupEntityReference( - ha_entity_registry.async_get(entity_ref.reference_id).name, - ha_entity_registry.async_get(entity_ref.reference_id).original_name, - entity_ref.reference_id, - )._asdict() - for entity_ref in zha_device_registry.get(self.device.ieee) - if list(entity_ref.cluster_handlers.values())[ - 0 - ].cluster.endpoint.endpoint_id - == self.endpoint_id - ] + + entity_info = [] + + for entity_ref in zha_device_registry.get(self.device.ieee): + entity = entity_registry.async_get(entity_ref.reference_id) + handler = list(entity_ref.cluster_handlers.values())[0] + + if ( + entity is None + or handler.cluster.endpoint.endpoint_id != self.endpoint_id + ): + continue + + entity_info.append( + GroupEntityReference( + name=entity.name, + original_name=entity.original_name, + entity_id=entity_ref.reference_id, + )._asdict() + ) + + return entity_info async def async_remove_from_group(self) -> None: """Remove the device endpoint from the provided zigbee group.""" @@ -204,12 +215,14 @@ def member_entity_ids(self) -> list[str]: def get_domain_entity_ids(self, domain: str) -> list[str]: """Return entity ids from the entity domain for this group.""" + entity_registry = er.async_get(self.hass) domain_entity_ids: list[str] = [] + for member in self.members: if member.device.is_coordinator: continue entities = async_entries_for_device( - self._zha_gateway.ha_entity_registry, + entity_registry, member.device.device_id, include_disabled_entities=True, ) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 7b0d062738b4ae..4df546b449c279 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -7,7 +7,9 @@ import asyncio import binascii +import collections from collections.abc import Callable, Iterator +import dataclasses from dataclasses import dataclass import enum import functools @@ -26,16 +28,12 @@ import zigpy.zdo.types as zdo_types from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, State, callback from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers.typing import ConfigType -from .const import ( - CLUSTER_TYPE_IN, - CLUSTER_TYPE_OUT, - CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, -) +from .const import CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, DATA_ZHA from .registries import BINDABLE_CLUSTERS if TYPE_CHECKING: @@ -221,7 +219,7 @@ def async_get_zha_config_value( def async_cluster_exists(hass, cluster_id, skip_coordinator=True): """Determine if a device containing the specified in cluster is paired.""" - zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) zha_devices = zha_gateway.devices.values() for zha_device in zha_devices: if skip_coordinator and zha_device.is_coordinator: @@ -244,7 +242,7 @@ def async_get_zha_device(hass: HomeAssistant, device_id: str) -> ZHADevice: if not registry_device: _LOGGER.error("Device id `%s` not found in registry", device_id) raise KeyError(f"Device id `{device_id}` not found in registry.") - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) try: ieee_address = list(registry_device.identifiers)[0][1] ieee = zigpy.types.EUI64.convert(ieee_address) @@ -421,3 +419,30 @@ def qr_to_install_code(qr_code: str) -> tuple[zigpy.types.EUI64, bytes]: return ieee, install_code raise vol.Invalid(f"couldn't convert qr code: {qr_code}") + + +@dataclasses.dataclass(kw_only=True, slots=True) +class ZHAData: + """ZHA component data stored in `hass.data`.""" + + yaml_config: ConfigType = dataclasses.field(default_factory=dict) + platforms: collections.defaultdict[Platform, list] = dataclasses.field( + default_factory=lambda: collections.defaultdict(list) + ) + gateway: ZHAGateway | None = dataclasses.field(default=None) + device_trigger_cache: dict[str, tuple[str, dict]] = dataclasses.field( + default_factory=dict + ) + + +def get_zha_data(hass: HomeAssistant) -> ZHAData: + """Get the global ZHA data object.""" + return hass.data.get(DATA_ZHA, ZHAData()) + + +def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway: + """Get the ZHA gateway object.""" + if (zha_gateway := get_zha_data(hass).gateway) is None: + raise ValueError("No gateway object exists") + + return zha_gateway diff --git a/homeassistant/components/zha/core/registries.py b/homeassistant/components/zha/core/registries.py index 713d10ddf704d8..74f724bdc493dd 100644 --- a/homeassistant/components/zha/core/registries.py +++ b/homeassistant/components/zha/core/registries.py @@ -269,15 +269,15 @@ class ZHAEntityRegistry: def __init__(self) -> None: """Initialize Registry instance.""" self._strict_registry: dict[ - str, dict[MatchRule, type[ZhaEntity]] + Platform, dict[MatchRule, type[ZhaEntity]] ] = collections.defaultdict(dict) self._multi_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) self._config_diagnostic_entity_registry: dict[ - str, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] + Platform, dict[int | str | None, dict[MatchRule, list[type[ZhaEntity]]]] ] = collections.defaultdict( lambda: collections.defaultdict(lambda: collections.defaultdict(list)) ) @@ -288,7 +288,7 @@ def __init__(self) -> None: def get_entity( self, - component: str, + component: Platform, manufacturer: str, model: str, cluster_handlers: list[ClusterHandler], @@ -310,10 +310,12 @@ def get_multi_entity( model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for component, stop_match_groups in self._multi_entity_registry.items(): @@ -341,10 +343,12 @@ def get_config_diagnostic_entity( model: str, cluster_handlers: list[ClusterHandler], quirk_class: str, - ) -> tuple[dict[str, list[EntityClassAndClusterHandlers]], list[ClusterHandler]]: + ) -> tuple[ + dict[Platform, list[EntityClassAndClusterHandlers]], list[ClusterHandler] + ]: """Match ZHA cluster handlers to potentially multiple ZHA Entity classes.""" result: dict[ - str, list[EntityClassAndClusterHandlers] + Platform, list[EntityClassAndClusterHandlers] ] = collections.defaultdict(list) all_claimed: set[ClusterHandler] = set() for ( @@ -375,7 +379,7 @@ def get_group_entity(self, component: str) -> type[ZhaGroupEntity] | None: def strict_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -406,7 +410,7 @@ def decorator(zha_ent: _ZhaEntityT) -> _ZhaEntityT: def multipass_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -441,7 +445,7 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: def config_diagnostic_match( self, - component: str, + component: Platform, cluster_handler_names: set[str] | str | None = None, generic_ids: set[str] | str | None = None, manufacturers: Callable | set[str] | str | None = None, @@ -475,7 +479,7 @@ def decorator(zha_entity: _ZhaEntityT) -> _ZhaEntityT: return decorator def group_match( - self, component: str + self, component: Platform ) -> Callable[[_ZhaGroupEntityT], _ZhaGroupEntityT]: """Decorate a group match rule.""" diff --git a/homeassistant/components/zha/cover.py b/homeassistant/components/zha/cover.py index 0d7062173ca2f5..f2aed0390f3846 100644 --- a/homeassistant/components/zha/cover.py +++ b/homeassistant/components/zha/cover.py @@ -33,11 +33,11 @@ CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_SHADE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation cover from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.COVER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.COVER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_tracker.py b/homeassistant/components/zha/device_tracker.py index bda346624dd690..ea27c58eb1926a 100644 --- a/homeassistant/components/zha/device_tracker.py +++ b/homeassistant/components/zha/device_tracker.py @@ -15,10 +15,10 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_POWER_CONFIGURATION, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity from .sensor import Battery @@ -32,7 +32,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation device tracker from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.DEVICE_TRACKER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.DEVICE_TRACKER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/device_trigger.py b/homeassistant/components/zha/device_trigger.py index 7a479443377370..a2ae734b8fc2cf 100644 --- a/homeassistant/components/zha/device_trigger.py +++ b/homeassistant/components/zha/device_trigger.py @@ -14,8 +14,8 @@ from homeassistant.helpers.typing import ConfigType from . import DOMAIN as ZHA_DOMAIN -from .core.const import DATA_ZHA, DATA_ZHA_DEVICE_TRIGGER_CACHE, ZHA_EVENT -from .core.helpers import async_get_zha_device +from .core.const import ZHA_EVENT +from .core.helpers import async_get_zha_device, get_zha_data CONF_SUBTYPE = "subtype" DEVICE = "device" @@ -32,13 +32,13 @@ def _get_device_trigger_data(hass: HomeAssistant, device_id: str) -> tuple[str, # First, try checking to see if the device itself is accessible try: zha_device = async_get_zha_device(hass, device_id) - except KeyError: + except ValueError: pass else: return str(zha_device.ieee), zha_device.device_automation_triggers # If not, check the trigger cache but allow any `KeyError`s to propagate - return hass.data[DATA_ZHA][DATA_ZHA_DEVICE_TRIGGER_CACHE][device_id] + return get_zha_data(hass).device_trigger_cache[device_id] async def async_validate_trigger_config( diff --git a/homeassistant/components/zha/diagnostics.py b/homeassistant/components/zha/diagnostics.py index 966f35fe98bbbe..0fa1de5ff0ee5c 100644 --- a/homeassistant/components/zha/diagnostics.py +++ b/homeassistant/components/zha/diagnostics.py @@ -25,14 +25,10 @@ ATTR_PROFILE_ID, ATTR_VALUE, CONF_ALARM_MASTER_CODE, - DATA_ZHA, - DATA_ZHA_CONFIG, - DATA_ZHA_GATEWAY, UNKNOWN, ) from .core.device import ZHADevice -from .core.gateway import ZHAGateway -from .core.helpers import async_get_zha_device +from .core.helpers import async_get_zha_device, get_zha_data, get_zha_gateway KEYS_TO_REDACT = { ATTR_IEEE, @@ -66,18 +62,18 @@ async def async_get_config_entry_diagnostics( hass: HomeAssistant, config_entry: ConfigEntry ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {}) - gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_data = get_zha_data(hass) + app = get_zha_gateway(hass).application_controller - energy_scan = await gateway.application_controller.energy_scan( + energy_scan = await app.energy_scan( channels=Channels.ALL_CHANNELS, duration_exp=4, count=1 ) return async_redact_data( { - "config": config, + "config": zha_data.yaml_config, "config_entry": config_entry.as_dict(), - "application_state": shallow_asdict(gateway.application_controller.state), + "application_state": shallow_asdict(app.state), "energy_scan": { channel: 100 * energy / 255 for channel, energy in energy_scan.items() }, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index f2b16a3783423c..5722d91116ab59 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -26,14 +26,12 @@ from .core.const import ( ATTR_MANUFACTURER, ATTR_MODEL, - DATA_ZHA, - DATA_ZHA_BRIDGE_ID, DOMAIN, SIGNAL_GROUP_ENTITY_REMOVED, SIGNAL_GROUP_MEMBERSHIP_CHANGE, SIGNAL_REMOVE, ) -from .core.helpers import LogMixin +from .core.helpers import LogMixin, get_zha_gateway if TYPE_CHECKING: from .core.cluster_handlers import ClusterHandler @@ -83,13 +81,16 @@ def device_info(self) -> DeviceInfo: """Return a device description for device registry.""" zha_device_info = self._zha_device.device_info ieee = zha_device_info["ieee"] + + zha_gateway = get_zha_gateway(self.hass) + return DeviceInfo( connections={(CONNECTION_ZIGBEE, ieee)}, identifiers={(DOMAIN, ieee)}, manufacturer=zha_device_info[ATTR_MANUFACTURER], model=zha_device_info[ATTR_MODEL], name=zha_device_info[ATTR_NAME], - via_device=(DOMAIN, self.hass.data[DATA_ZHA][DATA_ZHA_BRIDGE_ID]), + via_device=(DOMAIN, zha_gateway.coordinator_ieee), ) @callback diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index a24272c9a7a0d8..73b128db10947f 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -28,12 +28,8 @@ from .core import discovery from .core.cluster_handlers import wrap_zigpy_exceptions -from .core.const import ( - CLUSTER_HANDLER_FAN, - DATA_ZHA, - SIGNAL_ADD_ENTITIES, - SIGNAL_ATTR_UPDATED, -) +from .core.const import CLUSTER_HANDLER_FAN, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -65,7 +61,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation fan from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.FAN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.FAN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 2ec424314980ad..967d0fc9134694 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -47,13 +47,12 @@ CONF_ENABLE_ENHANCED_LIGHT_TRANSITION, CONF_ENABLE_LIGHT_TRANSITIONING_FLAG, CONF_GROUP_MEMBERS_ASSUME_STATE, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL, ZHA_OPTIONS, ) -from .core.helpers import LogMixin, async_get_zha_config_value +from .core.helpers import LogMixin, async_get_zha_config_value, get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -97,7 +96,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation light from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LIGHT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LIGHT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/lock.py b/homeassistant/components/zha/lock.py index 1e68e95c88142e..9bac9a59a389ff 100644 --- a/homeassistant/components/zha/lock.py +++ b/homeassistant/components/zha/lock.py @@ -20,10 +20,10 @@ from .core import discovery from .core.const import ( CLUSTER_HANDLER_DOORLOCK, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -45,7 +45,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Door Lock from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.LOCK] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.LOCK] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/number.py b/homeassistant/components/zha/number.py index c12060eb2a8542..b6876155312fc8 100644 --- a/homeassistant/components/zha/number.py +++ b/homeassistant/components/zha/number.py @@ -20,10 +20,10 @@ CLUSTER_HANDLER_COLOR, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_LEVEL, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -258,7 +258,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation Analog Output from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.NUMBER] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.NUMBER] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/radio_manager.py b/homeassistant/components/zha/radio_manager.py index df30a85cd7bb82..ca03060075171f 100644 --- a/homeassistant/components/zha/radio_manager.py +++ b/homeassistant/components/zha/radio_manager.py @@ -26,12 +26,11 @@ CONF_DATABASE, CONF_RADIO_TYPE, CONF_ZIGPY, - DATA_ZHA, - DATA_ZHA_CONFIG, DEFAULT_DATABASE_NAME, EZSP_OVERWRITE_EUI64, RadioType, ) +from .core.helpers import get_zha_data # Only the common radio types will be autoprobed, ordered by new device popularity. # XBee takes too long to probe since it scans through all possible bauds and likely has @@ -145,7 +144,7 @@ async def connect_zigpy_app(self) -> ControllerApplication: """Connect to the radio with the current config and then clean up.""" assert self.radio_type is not None - config = self.hass.data.get(DATA_ZHA, {}).get(DATA_ZHA_CONFIG, {}) + config = get_zha_data(self.hass).yaml_config app_config = config.get(CONF_ZIGPY, {}).copy() database_path = config.get( diff --git a/homeassistant/components/zha/select.py b/homeassistant/components/zha/select.py index 018f24675e7023..fa2e124fd05495 100644 --- a/homeassistant/components/zha/select.py +++ b/homeassistant/components/zha/select.py @@ -23,11 +23,11 @@ CLUSTER_HANDLER_IAS_WD, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -48,7 +48,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SELECT] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SELECT] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 535733230b93a9..1e166675b5b75f 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -57,10 +57,10 @@ CLUSTER_HANDLER_SOIL_MOISTURE, CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_THERMOSTAT, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .entity import ZhaEntity @@ -99,7 +99,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation sensor from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SENSOR] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SENSOR] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/siren.py b/homeassistant/components/zha/siren.py index a4c699d515ba63..86cadb625198d9 100644 --- a/homeassistant/components/zha/siren.py +++ b/homeassistant/components/zha/siren.py @@ -25,7 +25,6 @@ from .core.cluster_handlers.security import IasWd from .core.const import ( CLUSTER_HANDLER_IAS_WD, - DATA_ZHA, SIGNAL_ADD_ENTITIES, WARNING_DEVICE_MODE_BURGLAR, WARNING_DEVICE_MODE_EMERGENCY, @@ -39,6 +38,7 @@ WARNING_DEVICE_STROBE_NO, Strobe, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity @@ -56,7 +56,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation siren from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SIREN] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SIREN] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 8707dda629fe91..eff8f727c1c896 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -20,10 +20,10 @@ CLUSTER_HANDLER_BASIC, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, - DATA_ZHA, SIGNAL_ADD_ENTITIES, SIGNAL_ATTR_UPDATED, ) +from .core.helpers import get_zha_data from .core.registries import ZHA_ENTITIES from .entity import ZhaEntity, ZhaGroupEntity @@ -46,7 +46,8 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Zigbee Home Automation switch from config entry.""" - entities_to_create = hass.data[DATA_ZHA][Platform.SWITCH] + zha_data = get_zha_data(hass) + entities_to_create = zha_data.platforms[Platform.SWITCH] unsub = async_dispatcher_connect( hass, diff --git a/homeassistant/components/zha/websocket_api.py b/homeassistant/components/zha/websocket_api.py index 97862bd36f05dd..51941248f03cbc 100644 --- a/homeassistant/components/zha/websocket_api.py +++ b/homeassistant/components/zha/websocket_api.py @@ -16,6 +16,7 @@ from homeassistant.components import websocket_api from homeassistant.const import ATTR_COMMAND, ATTR_ID, ATTR_NAME from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import entity_registry as er import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.service import async_register_admin_service @@ -52,8 +53,6 @@ CLUSTER_TYPE_IN, CLUSTER_TYPE_OUT, CUSTOM_CONFIGURATION, - DATA_ZHA, - DATA_ZHA_GATEWAY, DOMAIN, EZSP_OVERWRITE_EUI64, GROUP_ID, @@ -77,6 +76,7 @@ cluster_command_schema_to_vol_schema, convert_install_code, get_matched_clusters, + get_zha_gateway, qr_to_install_code, ) @@ -301,7 +301,7 @@ async def websocket_permit_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Permit ZHA zigbee devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) duration: int = msg[ATTR_DURATION] ieee: EUI64 | None = msg.get(ATTR_IEEE) @@ -348,7 +348,7 @@ async def websocket_get_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device.zha_device_info for device in zha_gateway.devices.values()] connection.send_result(msg[ID], devices) @@ -357,7 +357,8 @@ async def websocket_get_devices( def _get_entity_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.name if entry else None @@ -365,7 +366,8 @@ def _get_entity_name( def _get_entity_original_name( zha_gateway: ZHAGateway, entity_ref: EntityReference ) -> str | None: - entry = zha_gateway.ha_entity_registry.async_get(entity_ref.reference_id) + entity_registry = er.async_get(zha_gateway.hass) + entry = entity_registry.async_get(entity_ref.reference_id) return entry.original_name if entry else None @@ -376,7 +378,7 @@ async def websocket_get_groupable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices that can be grouped.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) devices = [device for device in zha_gateway.devices.values() if device.is_groupable] groupable_devices = [] @@ -414,7 +416,7 @@ async def websocket_get_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) groups = [group.group_info for group in zha_gateway.groups.values()] connection.send_result(msg[ID], groups) @@ -431,7 +433,7 @@ async def websocket_get_device( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] if not (zha_device := zha_gateway.devices.get(ieee)): @@ -458,7 +460,7 @@ async def websocket_get_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] if not (zha_group := zha_gateway.groups.get(group_id)): @@ -487,7 +489,7 @@ async def websocket_add_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add a new ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_name: str = msg[GROUP_NAME] group_id: int | None = msg.get(GROUP_ID) members: list[GroupMember] | None = msg.get(ATTR_MEMBERS) @@ -508,7 +510,7 @@ async def websocket_remove_groups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove the specified ZHA groups.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_ids: list[int] = msg[GROUP_IDS] if len(group_ids) > 1: @@ -535,7 +537,7 @@ async def websocket_add_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Add members to a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -565,7 +567,7 @@ async def websocket_remove_group_members( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove members from a ZHA group.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) group_id: int = msg[GROUP_ID] members: list[GroupMember] = msg[ATTR_MEMBERS] @@ -594,7 +596,7 @@ async def websocket_reconfigure_node( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Reconfigure a ZHA nodes entities by its ieee address.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] device: ZHADevice | None = zha_gateway.get_device(ieee) @@ -629,7 +631,7 @@ async def websocket_update_topology( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA network topology.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) hass.async_create_task(zha_gateway.application_controller.topology.scan()) @@ -645,7 +647,7 @@ async def websocket_device_clusters( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of device clusters.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] zha_device = zha_gateway.get_device(ieee) response_clusters = [] @@ -689,7 +691,7 @@ async def websocket_device_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Return a list of cluster attributes.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -736,7 +738,7 @@ async def websocket_device_cluster_commands( """Return a list of cluster commands.""" import voluptuous_serialize # pylint: disable=import-outside-toplevel - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -806,7 +808,7 @@ async def websocket_read_zigbee_cluster_attributes( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Read zigbee attribute for cluster on ZHA entity.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = msg[ATTR_IEEE] endpoint_id: int = msg[ATTR_ENDPOINT_ID] cluster_id: int = msg[ATTR_CLUSTER_ID] @@ -860,7 +862,7 @@ async def websocket_get_bindable_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_IEEE] source_device = zha_gateway.get_device(source_ieee) @@ -894,7 +896,7 @@ async def websocket_bind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -923,7 +925,7 @@ async def websocket_unbind_devices( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Remove a direct binding between devices.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] target_ieee: EUI64 = msg[ATTR_TARGET_IEEE] await async_binding_operation( @@ -953,7 +955,7 @@ async def websocket_bind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Directly bind a device to a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -977,7 +979,7 @@ async def websocket_unbind_group( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Unbind a device from a group.""" - zha_gateway: ZHAGateway = get_gateway(hass) + zha_gateway = get_zha_gateway(hass) source_ieee: EUI64 = msg[ATTR_SOURCE_IEEE] group_id: int = msg[GROUP_ID] bindings: list[ClusterBinding] = msg[BINDINGS] @@ -987,11 +989,6 @@ async def websocket_unbind_group( connection.send_result(msg[ID]) -def get_gateway(hass: HomeAssistant) -> ZHAGateway: - """Return Gateway, mainly as fixture for mocking during testing.""" - return hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] - - async def async_binding_operation( zha_gateway: ZHAGateway, source_ieee: EUI64, @@ -1047,7 +1044,7 @@ async def websocket_get_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) import voluptuous_serialize # pylint: disable=import-outside-toplevel def custom_serializer(schema: Any) -> Any: @@ -1094,7 +1091,7 @@ async def websocket_update_zha_configuration( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Update the ZHA configuration.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) options = zha_gateway.config_entry.options data_to_save = {**options, **{CUSTOM_CONFIGURATION: msg["data"]}} @@ -1141,7 +1138,7 @@ async def websocket_get_network_settings( ) -> None: """Get ZHA network settings.""" backup = async_get_active_network_settings(hass) - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) connection.send_result( msg[ID], { @@ -1159,7 +1156,7 @@ async def websocket_list_network_backups( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Get ZHA network settings.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # Serialize known backups @@ -1175,7 +1172,7 @@ async def websocket_create_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Create a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller # This can take 5-30s @@ -1202,7 +1199,7 @@ async def websocket_restore_network_backup( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Restore a ZHA network backup.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller backup = msg["backup"] @@ -1240,7 +1237,7 @@ async def websocket_change_channel( @callback def async_load_api(hass: HomeAssistant) -> None: """Set up the web socket API.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) application_controller = zha_gateway.application_controller async def permit(service: ServiceCall) -> None: @@ -1278,7 +1275,7 @@ async def permit(service: ServiceCall) -> None: async def remove(service: ServiceCall) -> None: """Remove a node from the network.""" - zha_gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) ieee: EUI64 = service.data[ATTR_IEEE] zha_device: ZHADevice | None = zha_gateway.get_device(ieee) if zha_device is not None and zha_device.is_active_coordinator: diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index db1da3721ee42f..44155d741b7492 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -9,7 +9,10 @@ import zigpy.zcl.foundation as zcl_f import homeassistant.components.zha.core.const as zha_const -from homeassistant.components.zha.core.helpers import async_get_zha_config_value +from homeassistant.components.zha.core.helpers import ( + async_get_zha_config_value, + get_zha_gateway, +) from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util @@ -85,11 +88,6 @@ def update_attribute_cache(cluster): cluster.handle_message(hdr, msg) -def get_zha_gateway(hass): - """Return ZHA gateway from hass.data.""" - return hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] - - def make_attribute(attrid, value, status=0): """Make an attribute.""" attr = zcl_f.Attribute() diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index 7d391872a77d4c..e7dc7316f7328a 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -22,9 +22,10 @@ import homeassistant.components.zha.core.const as zha_const import homeassistant.components.zha.core.device as zha_core_device +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.setup import async_setup_component -from . import common +from .common import patch_cluster as common_patch_cluster from tests.common import MockConfigEntry from tests.components.light.conftest import mock_light_profiles # noqa: F401 @@ -277,7 +278,7 @@ def _mock_dev( for cluster in itertools.chain( endpoint.in_clusters.values(), endpoint.out_clusters.values() ): - common.patch_cluster(cluster) + common_patch_cluster(cluster) if attributes is not None: for ep_id, clusters in attributes.items(): @@ -305,7 +306,7 @@ async def _zha_device(zigpy_dev, *, setup_zha: bool = True): if setup_zha: await setup_zha_fixture() - zha_gateway = common.get_zha_gateway(hass) + zha_gateway = get_zha_gateway(hass) zha_gateway.application_controller.devices[zigpy_dev.ieee] = zigpy_dev await zha_gateway.async_device_initialized(zigpy_dev) await hass.async_block_till_done() @@ -329,7 +330,7 @@ async def _zha_device(zigpy_dev, *, last_seen=None, setup_zha: bool = True): if setup_zha: await setup_zha_fixture() - zha_gateway = hass.data[zha_const.DATA_ZHA][zha_const.DATA_ZHA_GATEWAY] + zha_gateway = get_zha_gateway(hass) return zha_gateway.get_device(zigpy_dev.ieee) return _zha_device diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index c2cb16efcc8b67..89742fb1e49442 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -11,6 +11,7 @@ from homeassistant.components import zha from homeassistant.components.zha import api from homeassistant.components.zha.core.const import RadioType +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -40,7 +41,7 @@ async def test_async_get_network_settings_inactive( """Test reading settings with an inactive ZHA installation.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) backup = zigpy.backups.NetworkBackup() @@ -70,7 +71,7 @@ async def test_async_get_network_settings_missing( """Test reading settings with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await gateway.config_entry.async_unload(hass) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_cluster_handlers.py b/tests/components/zha/test_cluster_handlers.py index 7e0e8eaab85c7d..24162296cd504a 100644 --- a/tests/components/zha/test_cluster_handlers.py +++ b/tests/components/zha/test_cluster_handlers.py @@ -20,11 +20,12 @@ import homeassistant.components.zha.core.const as zha_const from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as registries from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from .common import get_zha_gateway, make_zcl_header +from .common import make_zcl_header from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE from tests.common import async_capture_events diff --git a/tests/components/zha/test_device_action.py b/tests/components/zha/test_device_action.py index 31ffe9449e2b48..229fde89f15da6 100644 --- a/tests/components/zha/test_device_action.py +++ b/tests/components/zha/test_device_action.py @@ -108,21 +108,19 @@ async def test_get_actions(hass: HomeAssistant, device_ias) -> None: ieee_address = str(device_ias[0].ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - ha_entity_registry = er.async_get(hass) - siren_level_select = ha_entity_registry.async_get( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + entity_registry = er.async_get(hass) + siren_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_level" ) - siren_tone_select = ha_entity_registry.async_get( + siren_tone_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_siren_tone" ) - strobe_level_select = ha_entity_registry.async_get( + strobe_level_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe_level" ) - strobe_select = ha_entity_registry.async_get( + strobe_select = entity_registry.async_get( "select.fakemanufacturer_fakemodel_default_strobe" ) @@ -171,13 +169,13 @@ async def test_get_inovelli_actions(hass: HomeAssistant, device_inovelli) -> Non """Test we get the expected actions from a ZHA device.""" inovelli_ieee_address = str(device_inovelli[0].ieee) - ha_device_registry = dr.async_get(hass) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) - ha_entity_registry = er.async_get(hass) - inovelli_button = ha_entity_registry.async_get("button.inovelli_vzm31_sn_identify") - inovelli_light = ha_entity_registry.async_get("light.inovelli_vzm31_sn_light") + entity_registry = er.async_get(hass) + inovelli_button = entity_registry.async_get("button.inovelli_vzm31_sn_identify") + inovelli_light = entity_registry.async_get("light.inovelli_vzm31_sn_light") actions = await async_get_device_automations( hass, DeviceAutomationType.ACTION, inovelli_reg_device.id @@ -262,11 +260,9 @@ async def test_action(hass: HomeAssistant, device_ias, device_inovelli) -> None: ieee_address = str(zha_device.ieee) inovelli_ieee_address = str(inovelli_zha_device.ieee) - ha_device_registry = dr.async_get(hass) - reg_device = ha_device_registry.async_get_device( - identifiers={(DOMAIN, ieee_address)} - ) - inovelli_reg_device = ha_device_registry.async_get_device( + device_registry = dr.async_get(hass) + reg_device = device_registry.async_get_device(identifiers={(DOMAIN, ieee_address)}) + inovelli_reg_device = device_registry.async_get_device( identifiers={(DOMAIN, inovelli_ieee_address)} ) diff --git a/tests/components/zha/test_device_trigger.py b/tests/components/zha/test_device_trigger.py index 491e2d96d4f70b..096d83567fefed 100644 --- a/tests/components/zha/test_device_trigger.py +++ b/tests/components/zha/test_device_trigger.py @@ -477,6 +477,7 @@ async def test_validate_trigger_config_unloaded_bad_info( # Reload ZHA to persist the device info in the cache await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() await hass.config_entries.async_unload(config_entry.entry_id) ha_device_registry = dr.async_get(hass) diff --git a/tests/components/zha/test_diagnostics.py b/tests/components/zha/test_diagnostics.py index 6bcb321ab140bb..c13bb36c1c0e19 100644 --- a/tests/components/zha/test_diagnostics.py +++ b/tests/components/zha/test_diagnostics.py @@ -6,8 +6,8 @@ import zigpy.zcl.clusters.security as security from homeassistant.components.diagnostics import REDACTED -from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY from homeassistant.components.zha.core.device import ZHADevice +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -65,7 +65,7 @@ async def test_diagnostics_for_config_entry( """Test diagnostics for config entry.""" await zha_device_joined(zigpy_device) - gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + gateway = get_zha_gateway(hass) scan = {c: c for c in range(11, 26 + 1)} with patch.object(gateway.application_controller, "energy_scan", return_value=scan): diff --git a/tests/components/zha/test_discover.py b/tests/components/zha/test_discover.py index e0785601b4f4c4..768f974d9286d4 100644 --- a/tests/components/zha/test_discover.py +++ b/tests/components/zha/test_discover.py @@ -20,12 +20,12 @@ from homeassistant.components.zha.core.device import ZHADevice import homeassistant.components.zha.core.discovery as disc from homeassistant.components.zha.core.endpoint import Endpoint +from homeassistant.components.zha.core.helpers import get_zha_gateway import homeassistant.components.zha.core.registries as zha_regs from homeassistant.const import Platform from homeassistant.core import HomeAssistant import homeassistant.helpers.entity_registry as er -from .common import get_zha_gateway from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .zha_devices_list import ( DEV_SIG_ATTRIBUTES, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 3d0b065ab18a28..81ab1c2e0f5a32 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -21,6 +21,7 @@ from homeassistant.components.zha.core.device import ZHADevice from homeassistant.components.zha.core.discovery import GROUP_PROBE from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.fan import ( PRESET_MODE_AUTO, PRESET_MODE_ON, @@ -45,7 +46,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE diff --git a/tests/components/zha/test_gateway.py b/tests/components/zha/test_gateway.py index 0f791a08955926..214bfcad9f0340 100644 --- a/tests/components/zha/test_gateway.py +++ b/tests/components/zha/test_gateway.py @@ -11,11 +11,12 @@ 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 from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from .common import async_find_group_entity_id, get_zha_gateway +from .common import async_find_group_entity_id from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE IEEE_GROUPABLE_DEVICE = "01:2d:6f:00:0a:90:69:e8" diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index c1f5cf04e35727..da91340b864e09 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -20,9 +20,11 @@ ZHA_OPTIONS, ) from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.components.zha.light import FLASH_EFFECTS from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er import homeassistant.util.dt as dt_util from .common import ( @@ -32,7 +34,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, patch_zha_config, send_attributes_report, update_attribute_cache, @@ -1781,7 +1782,8 @@ async def test_zha_group_light_entity( assert device_3_entity_id not in zha_group.member_entity_ids # make sure the entity registry entry is still there - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None # add a member back and ensure that the group entity was created again await zha_group.async_add_members([GroupMember(device_light_3.ieee, 1)]) @@ -1811,10 +1813,10 @@ async def test_zha_group_light_entity( assert len(zha_group.members) == 3 # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None @patch( @@ -1914,7 +1916,8 @@ async def test_group_member_assume_state( assert hass.states.get(group_entity_id).state == STATE_OFF # remove the group and ensure that there is no entity and that the entity registry is cleaned up - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is not None + entity_registry = er.async_get(hass) + assert entity_registry.async_get(group_entity_id) is not None await zha_gateway.async_remove_zigpy_group(zha_group.group_id) assert hass.states.get(group_entity_id) is None - assert zha_gateway.ha_entity_registry.async_get(group_entity_id) is None + assert entity_registry.async_get(group_entity_id) is None diff --git a/tests/components/zha/test_silabs_multiprotocol.py b/tests/components/zha/test_silabs_multiprotocol.py index beae0230901e59..4d11ae81b089e0 100644 --- a/tests/components/zha/test_silabs_multiprotocol.py +++ b/tests/components/zha/test_silabs_multiprotocol.py @@ -9,7 +9,8 @@ import zigpy.state from homeassistant.components import zha -from homeassistant.components.zha import api, silabs_multiprotocol +from homeassistant.components.zha import silabs_multiprotocol +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.core import HomeAssistant if TYPE_CHECKING: @@ -36,7 +37,7 @@ async def test_async_get_channel_missing( """Test reading channel with an inactive ZHA installation, no valid channel.""" await setup_zha() - gateway = api._get_gateway(hass) + gateway = get_zha_gateway(hass) await zha.async_unload_entry(hass, gateway.config_entry) # Network settings were never loaded for whatever reason diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index fe7450eff67c94..b07b34763d10d5 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -19,6 +19,7 @@ from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.zha.core.group import GroupMember +from homeassistant.components.zha.core.helpers import get_zha_gateway from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -30,7 +31,6 @@ async_test_rejoin, async_wait_for_updates, find_entity_id, - get_zha_gateway, send_attributes_report, ) from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_TYPE diff --git a/tests/components/zha/test_websocket_api.py b/tests/components/zha/test_websocket_api.py index 740ffd6c06c6ab..b0e15a013189f0 100644 --- a/tests/components/zha/test_websocket_api.py +++ b/tests/components/zha/test_websocket_api.py @@ -940,6 +940,7 @@ async def test_websocket_bind_unbind_devices( @pytest.mark.parametrize("command_type", ["bind", "unbind"]) async def test_websocket_bind_unbind_group( command_type: str, + hass: HomeAssistant, app_controller: ControllerApplication, zha_client, ) -> None: @@ -947,8 +948,9 @@ async def test_websocket_bind_unbind_group( test_group_id = 0x0001 gateway_mock = MagicMock() + with patch( - "homeassistant.components.zha.websocket_api.get_gateway", + "homeassistant.components.zha.websocket_api.get_zha_gateway", return_value=gateway_mock, ): device_mock = MagicMock() From 0571a75c9982c25a0f48c96528ac767e921f3a1d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 14:42:13 -0500 Subject: [PATCH 352/640] Bump zeroconf to 0.108.0 (#100148) --- 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 8a91b14a846349..1e2205a1c1b833 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.107.0"] + "requirements": ["zeroconf==0.108.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd5fdcd9dd5384..c78d0343fb58b0 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.107.0 +zeroconf==0.108.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 1ecf291712035b..b073a0277820d3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.107.0 +zeroconf==0.108.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0180ee773ae9fe..85bc994a928db3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.107.0 +zeroconf==0.108.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 13cd873e388187f101fbfe12dfb86572d13436bf Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Mon, 11 Sep 2023 21:50:29 +0200 Subject: [PATCH 353/640] Fix devices not always reporting IP - bump aiounifi to v62 (#100149) --- homeassistant/components/unifi/device_tracker.py | 4 ++-- homeassistant/components/unifi/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 2b7ac04cc0d80f..746e3b1fcf00eb 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -138,7 +138,7 @@ class UnifiEntityTrackerDescriptionMixin(Generic[HandlerT, ApiItemT]): """Device tracker local functions.""" heartbeat_timedelta_fn: Callable[[UniFiController, str], timedelta] - ip_address_fn: Callable[[aiounifi.Controller, str], str] + ip_address_fn: Callable[[aiounifi.Controller, str], str | None] is_connected_fn: Callable[[UniFiController, str], bool] hostname_fn: Callable[[aiounifi.Controller, str], str | None] @@ -247,7 +247,7 @@ def hostname(self) -> str | None: return self.entity_description.hostname_fn(self.controller.api, self._obj_id) @property - def ip_address(self) -> str: + def ip_address(self) -> str | None: """Return the primary ip address of the device.""" return self.entity_description.ip_address_fn(self.controller.api, self._obj_id) diff --git a/homeassistant/components/unifi/manifest.json b/homeassistant/components/unifi/manifest.json index f20e5f9e4acabd..8734fd7dce555d 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==61"], + "requirements": ["aiounifi==62"], "ssdp": [ { "manufacturer": "Ubiquiti Networks", diff --git a/requirements_all.txt b/requirements_all.txt index b073a0277820d3..672c882b507b14 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -364,7 +364,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==61 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 85bc994a928db3..f6351dc12b3ec1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -339,7 +339,7 @@ aiosyncthing==0.5.1 aiotractive==0.5.6 # homeassistant.components.unifi -aiounifi==61 +aiounifi==62 # homeassistant.components.vlc_telnet aiovlc==0.1.0 From 5a56adb3f544c2c6e1a716db40aba100fc78a02d Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 21:53:07 +0200 Subject: [PATCH 354/640] Refactor discovergy config flow test to use parametrize (#100115) * Refactor discovergy config flow test to use parametrize * Formatting * Implement code review sugesstions --- .../components/discovergy/test_config_flow.py | 80 ++++--------------- 1 file changed, 17 insertions(+), 63 deletions(-) diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index ad9fde46b646f2..9665da65789fa9 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch from pydiscovergy.error import DiscovergyClientError, HTTPError, InvalidLogin +import pytest from homeassistant import data_entry_flow from homeassistant.components.discovergy.const import DOMAIN @@ -73,17 +74,27 @@ async def test_reauth( assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidLogin, "invalid_auth"), + (HTTPError, "cannot_connect"), + (DiscovergyClientError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: + """Test to handle exceptions.""" + result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) with patch( "pydiscovergy.Discovergy.meters", - side_effect=InvalidLogin, + side_effect=error, ): - result2 = await hass.config_entries.flow.async_configure( + result = await hass.config_entries.flow.async_configure( result["flow_id"], { CONF_EMAIL: "test@example.com", @@ -91,62 +102,5 @@ async def test_form_invalid_auth(hass: HomeAssistant) -> None: }, ) - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=HTTPError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_client_error(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=DiscovergyClientError): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} - - -async def test_form_unknown_exception(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - - with patch("pydiscovergy.Discovergy.meters", side_effect=Exception): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_EMAIL: "test@example.com", - CONF_PASSWORD: "test-password", - }, - ) - - assert result2["type"] == data_entry_flow.FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == {"base": message} From c347c78b6d7287f0f3f1d0b3a19f10cdae530f97 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 11 Sep 2023 22:25:08 +0200 Subject: [PATCH 355/640] Split Withings common file out to their own file (#100150) * Split common out in logical pieces * Split common out in logical pieces * Split common out in logical pieces --- .../withings/application_credentials.py | 51 +++++- .../components/withings/binary_sensor.py | 8 +- homeassistant/components/withings/common.py | 145 +----------------- homeassistant/components/withings/entity.py | 100 ++++++++++++ homeassistant/components/withings/sensor.py | 8 +- tests/components/withings/common.py | 5 +- tests/components/withings/test_sensor.py | 2 +- 7 files changed, 158 insertions(+), 161 deletions(-) create mode 100644 homeassistant/components/withings/entity.py diff --git a/homeassistant/components/withings/application_credentials.py b/homeassistant/components/withings/application_credentials.py index e5c401d5e7430b..1d5b52466c4fbf 100644 --- a/homeassistant/components/withings/application_credentials.py +++ b/homeassistant/components/withings/application_credentials.py @@ -1,15 +1,17 @@ """application_credentials platform for Withings.""" +from typing import Any + from withings_api import AbstractWithingsApi, WithingsAuth from homeassistant.components.application_credentials import ( + AuthImplementation, AuthorizationServer, ClientCredential, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import config_entry_oauth2_flow -from .common import WithingsLocalOAuth2Implementation from .const import DOMAIN @@ -26,3 +28,50 @@ async def async_get_auth_implementation( token_url=f"{AbstractWithingsApi.URL}/v2/oauth2", ), ) + + +class WithingsLocalOAuth2Implementation(AuthImplementation): + """Oauth2 implementation that only uses the external url.""" + + async def _token_request(self, data: dict) -> dict: + """Make a token request and adapt Withings API reply.""" + new_token = await super()._token_request(data) + # Withings API returns habitual token data under json key "body": + # { + # "status": [{integer} Withings API response status], + # "body": { + # "access_token": [{string} Your new access_token], + # "expires_in": [{integer} Access token expiry delay in seconds], + # "token_type": [{string] HTTP Authorization Header format: Bearer], + # "scope": [{string} Scopes the user accepted], + # "refresh_token": [{string} Your new refresh_token], + # "userid": [{string} The Withings ID of the user] + # } + # } + # so we copy that to token root. + if body := new_token.pop("body", None): + new_token.update(body) + return new_token + + async def async_resolve_external_data(self, external_data: Any) -> dict: + """Resolve the authorization code to tokens.""" + return await self._token_request( + { + "action": "requesttoken", + "grant_type": "authorization_code", + "code": external_data["code"], + "redirect_uri": external_data["state"]["redirect_uri"], + } + ) + + async def _async_refresh_token(self, token: dict) -> dict: + """Refresh tokens.""" + new_token = await self._token_request( + { + "action": "requesttoken", + "grant_type": "refresh_token", + "client_id": self.client_id, + "refresh_token": token["refresh_token"], + } + ) + return {**token, **new_token} diff --git a/homeassistant/components/withings/binary_sensor.py b/homeassistant/components/withings/binary_sensor.py index e1351d7c01951e..976774f23b34f8 100644 --- a/homeassistant/components/withings/binary_sensor.py +++ b/homeassistant/components/withings/binary_sensor.py @@ -14,13 +14,9 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import Measurement +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 516c306cc0f668..3d215567f450e7 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -28,7 +28,6 @@ ) from homeassistant.components import webhook -from homeassistant.components.application_credentials import AuthImplementation from homeassistant.components.http import HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_WEBHOOK_ID @@ -38,13 +37,11 @@ AbstractOAuth2Implementation, OAuth2Session, ) -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const -from .const import DOMAIN, Measurement +from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) _RETRY_COEFFICIENT = 0.5 @@ -64,20 +61,6 @@ class UpdateType(StrEnum): WEBHOOK = "webhook" -@dataclass -class WithingsEntityDescriptionMixin: - """Mixin for describing withings data.""" - - measurement: Measurement - measure_type: NotifyAppli | GetSleepSummaryField | MeasureType - update_type: UpdateType - - -@dataclass -class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): - """Immutable class for describing withings data.""" - - @dataclass class WebhookConfig: """Config for a webhook.""" @@ -538,85 +521,6 @@ async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: ) -def get_attribute_unique_id( - description: WithingsEntityDescription, user_id: int -) -> str: - """Get a entity unique id for a user's attribute.""" - return f"withings_{user_id}_{description.measurement.value}" - - -class BaseWithingsSensor(Entity): - """Base class for withings sensors.""" - - _attr_should_poll = False - entity_description: WithingsEntityDescription - _attr_has_entity_name = True - - def __init__( - self, data_manager: DataManager, description: WithingsEntityDescription - ) -> None: - """Initialize the Withings sensor.""" - self._data_manager = data_manager - self.entity_description = description - self._attr_unique_id = get_attribute_unique_id( - description, data_manager.user_id - ) - self._state_data: Any | None = None - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, - ) - - @property - def available(self) -> bool: - """Return True if entity is available.""" - if self.entity_description.update_type == UpdateType.POLL: - return self._data_manager.poll_data_update_coordinator.last_update_success - - if self.entity_description.update_type == UpdateType.WEBHOOK: - return self._data_manager.webhook_config.enabled and ( - self.entity_description.measurement - in self._data_manager.webhook_update_coordinator.data - ) - - return True - - @callback - def _on_poll_data_updated(self) -> None: - self._update_state_data( - self._data_manager.poll_data_update_coordinator.data or {} - ) - - @callback - def _on_webhook_data_updated(self) -> None: - self._update_state_data( - self._data_manager.webhook_update_coordinator.data or {} - ) - - def _update_state_data(self, data: dict[Measurement, Any]) -> None: - """Update the state data.""" - self._state_data = data.get(self.entity_description.measurement) - self.async_write_ha_state() - - async def async_added_to_hass(self) -> None: - """Register update dispatcher.""" - if self.entity_description.update_type == UpdateType.POLL: - self.async_on_remove( - self._data_manager.poll_data_update_coordinator.async_add_listener( - self._on_poll_data_updated - ) - ) - self._on_poll_data_updated() - - elif self.entity_description.update_type == UpdateType.WEBHOOK: - self.async_on_remove( - self._data_manager.webhook_update_coordinator.async_add_listener( - self._on_webhook_data_updated - ) - ) - self._on_webhook_data_updated() - - async def async_get_data_manager( hass: HomeAssistant, config_entry: ConfigEntry ) -> DataManager: @@ -680,50 +584,3 @@ def get_all_data_managers(hass: HomeAssistant) -> tuple[DataManager, ...]: def async_remove_data_manager(hass: HomeAssistant, config_entry: ConfigEntry) -> None: """Remove a data manager for a config entry.""" del hass.data[const.DOMAIN][config_entry.entry_id][const.DATA_MANAGER] - - -class WithingsLocalOAuth2Implementation(AuthImplementation): - """Oauth2 implementation that only uses the external url.""" - - async def _token_request(self, data: dict) -> dict: - """Make a token request and adapt Withings API reply.""" - new_token = await super()._token_request(data) - # Withings API returns habitual token data under json key "body": - # { - # "status": [{integer} Withings API response status], - # "body": { - # "access_token": [{string} Your new access_token], - # "expires_in": [{integer} Access token expiry delay in seconds], - # "token_type": [{string] HTTP Authorization Header format: Bearer], - # "scope": [{string} Scopes the user accepted], - # "refresh_token": [{string} Your new refresh_token], - # "userid": [{string} The Withings ID of the user] - # } - # } - # so we copy that to token root. - if body := new_token.pop("body", None): - new_token.update(body) - return new_token - - async def async_resolve_external_data(self, external_data: Any) -> dict: - """Resolve the authorization code to tokens.""" - return await self._token_request( - { - "action": "requesttoken", - "grant_type": "authorization_code", - "code": external_data["code"], - "redirect_uri": external_data["state"]["redirect_uri"], - } - ) - - async def _async_refresh_token(self, token: dict) -> dict: - """Refresh tokens.""" - new_token = await self._token_request( - { - "action": "requesttoken", - "grant_type": "refresh_token", - "client_id": self.client_id, - "refresh_token": token["refresh_token"], - } - ) - return {**token, **new_token} diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py new file mode 100644 index 00000000000000..a1ad8828b81c98 --- /dev/null +++ b/homeassistant/components/withings/entity.py @@ -0,0 +1,100 @@ +"""Base entity for Withings.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from withings_api.common import GetSleepSummaryField, MeasureType, NotifyAppli + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity, EntityDescription + +from .common import DataManager, UpdateType +from .const import DOMAIN, Measurement + + +@dataclass +class WithingsEntityDescriptionMixin: + """Mixin for describing withings data.""" + + measurement: Measurement + measure_type: NotifyAppli | GetSleepSummaryField | MeasureType + update_type: UpdateType + + +@dataclass +class WithingsEntityDescription(EntityDescription, WithingsEntityDescriptionMixin): + """Immutable class for describing withings data.""" + + +class BaseWithingsSensor(Entity): + """Base class for withings sensors.""" + + _attr_should_poll = False + entity_description: WithingsEntityDescription + _attr_has_entity_name = True + + def __init__( + self, data_manager: DataManager, description: WithingsEntityDescription + ) -> None: + """Initialize the Withings sensor.""" + self._data_manager = data_manager + self.entity_description = description + self._attr_unique_id = ( + f"withings_{data_manager.user_id}_{description.measurement.value}" + ) + self._state_data: Any | None = None + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, str(data_manager.user_id))}, + name=data_manager.profile, + ) + + @property + def available(self) -> bool: + """Return True if entity is available.""" + if self.entity_description.update_type == UpdateType.POLL: + return self._data_manager.poll_data_update_coordinator.last_update_success + + if self.entity_description.update_type == UpdateType.WEBHOOK: + return self._data_manager.webhook_config.enabled and ( + self.entity_description.measurement + in self._data_manager.webhook_update_coordinator.data + ) + + return True + + @callback + def _on_poll_data_updated(self) -> None: + self._update_state_data( + self._data_manager.poll_data_update_coordinator.data or {} + ) + + @callback + def _on_webhook_data_updated(self) -> None: + self._update_state_data( + self._data_manager.webhook_update_coordinator.data or {} + ) + + def _update_state_data(self, data: dict[Measurement, Any]) -> None: + """Update the state data.""" + self._state_data = data.get(self.entity_description.measurement) + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register update dispatcher.""" + if self.entity_description.update_type == UpdateType.POLL: + self.async_on_remove( + self._data_manager.poll_data_update_coordinator.async_add_listener( + self._on_poll_data_updated + ) + ) + self._on_poll_data_updated() + + elif self.entity_description.update_type == UpdateType.WEBHOOK: + self.async_on_remove( + self._data_manager.webhook_update_coordinator.async_add_listener( + self._on_webhook_data_updated + ) + ) + self._on_webhook_data_updated() diff --git a/homeassistant/components/withings/sensor.py b/homeassistant/components/withings/sensor.py index 4f98daacc42106..e8798adae2f059 100644 --- a/homeassistant/components/withings/sensor.py +++ b/homeassistant/components/withings/sensor.py @@ -23,12 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import ( - BaseWithingsSensor, - UpdateType, - WithingsEntityDescription, - async_get_data_manager, -) +from .common import UpdateType, async_get_data_manager from .const import ( SCORE_POINTS, UOM_BEATS_PER_MINUTE, @@ -37,6 +32,7 @@ UOM_MMHG, Measurement, ) +from .entity import BaseWithingsSensor, WithingsEntityDescription @dataclass diff --git a/tests/components/withings/common.py b/tests/components/withings/common.py index 6bb1b30917cf2d..7680b19e28901e 100644 --- a/tests/components/withings/common.py +++ b/tests/components/withings/common.py @@ -23,11 +23,10 @@ from homeassistant.components.withings.common import ( ConfigEntryWithingsApi, DataManager, - WithingsEntityDescription, get_all_data_managers, - get_attribute_unique_id, ) import homeassistant.components.withings.const as const +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.config import async_process_ha_core_config from homeassistant.config_entries import SOURCE_USER, ConfigEntry from homeassistant.const import ( @@ -324,6 +323,6 @@ async def async_get_entity_id( ) -> str | None: """Get an entity id for a user's attribute.""" entity_registry = er.async_get(hass) - unique_id = get_attribute_unique_id(description, user_id) + unique_id = f"withings_{user_id}_{description.measurement.value}" return entity_registry.async_get_entity_id(platform, const.DOMAIN, unique_id) diff --git a/tests/components/withings/test_sensor.py b/tests/components/withings/test_sensor.py index 4cc71df80d791f..cf0069c968a6f0 100644 --- a/tests/components/withings/test_sensor.py +++ b/tests/components/withings/test_sensor.py @@ -7,8 +7,8 @@ from withings_api.common import NotifyAppli from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.components.withings.common import WithingsEntityDescription from homeassistant.components.withings.const import Measurement +from homeassistant.components.withings.entity import WithingsEntityDescription from homeassistant.components.withings.sensor import SENSORS from homeassistant.core import HomeAssistant, State from homeassistant.helpers import entity_registry as er From 851dc4cdf48eab59c8179b60d1e5d114de6b00ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 12 Sep 2023 05:26:58 +0900 Subject: [PATCH 356/640] Use library for condition/wind direction conversions (#100117) 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/const.py | 124 ++++-------------- .../aemet/weather_update_coordinator.py | 19 +-- 2 files changed, 28 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/aemet/const.py b/homeassistant/components/aemet/const.py index c6c4a9c1628ed0..7940ff92f726ca 100644 --- a/homeassistant/components/aemet/const.py +++ b/homeassistant/components/aemet/const.py @@ -1,6 +1,19 @@ """Constant values for the AEMET OpenData component.""" from __future__ import annotations +from aemet_opendata.const import ( + AOD_COND_CLEAR_NIGHT, + AOD_COND_CLOUDY, + AOD_COND_FOG, + AOD_COND_LIGHTNING, + AOD_COND_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY, + AOD_COND_POURING, + AOD_COND_RAINY, + AOD_COND_SNOWY, + AOD_COND_SUNNY, +) + from homeassistant.components.weather import ( ATTR_CONDITION_CLEAR_NIGHT, ATTR_CONDITION_CLOUDY, @@ -55,94 +68,16 @@ ATTR_API_WIND_SPEED = "wind-speed" CONDITIONS_MAP = { - ATTR_CONDITION_CLEAR_NIGHT: { - "11n", # Despejado (de noche) - }, - ATTR_CONDITION_CLOUDY: { - "14", # Nuboso - "14n", # Nuboso (de noche) - "15", # Muy nuboso - "15n", # Muy nuboso (de noche) - "16", # Cubierto - "16n", # Cubierto (de noche) - "17", # Nubes altas - "17n", # Nubes altas (de noche) - }, - ATTR_CONDITION_FOG: { - "81", # Niebla - "81n", # Niebla (de noche) - "82", # Bruma - Neblina - "82n", # Bruma - Neblina (de noche) - }, - ATTR_CONDITION_LIGHTNING: { - "51", # Intervalos nubosos con tormenta - "51n", # Intervalos nubosos con tormenta (de noche) - "52", # Nuboso con tormenta - "52n", # Nuboso con tormenta (de noche) - "53", # Muy nuboso con tormenta - "53n", # Muy nuboso con tormenta (de noche) - "54", # Cubierto con tormenta - "54n", # Cubierto con tormenta (de noche) - }, - ATTR_CONDITION_LIGHTNING_RAINY: { - "61", # Intervalos nubosos con tormenta y lluvia escasa - "61n", # Intervalos nubosos con tormenta y lluvia escasa (de noche) - "62", # Nuboso con tormenta y lluvia escasa - "62n", # Nuboso con tormenta y lluvia escasa (de noche) - "63", # Muy nuboso con tormenta y lluvia escasa - "63n", # Muy nuboso con tormenta y lluvia escasa (de noche) - "64", # Cubierto con tormenta y lluvia escasa - "64n", # Cubierto con tormenta y lluvia escasa (de noche) - }, - ATTR_CONDITION_PARTLYCLOUDY: { - "12", # Poco nuboso - "12n", # Poco nuboso (de noche) - "13", # Intervalos nubosos - "13n", # Intervalos nubosos (de noche) - }, - ATTR_CONDITION_POURING: { - "27", # Chubascos - "27n", # Chubascos (de noche) - }, - ATTR_CONDITION_RAINY: { - "23", # Intervalos nubosos con lluvia - "23n", # Intervalos nubosos con lluvia (de noche) - "24", # Nuboso con lluvia - "24n", # Nuboso con lluvia (de noche) - "25", # Muy nuboso con lluvia - "25n", # Muy nuboso con lluvia (de noche) - "26", # Cubierto con lluvia - "26n", # Cubierto con lluvia (de noche) - "43", # Intervalos nubosos con lluvia escasa - "43n", # Intervalos nubosos con lluvia escasa (de noche) - "44", # Nuboso con lluvia escasa - "44n", # Nuboso con lluvia escasa (de noche) - "45", # Muy nuboso con lluvia escasa - "45n", # Muy nuboso con lluvia escasa (de noche) - "46", # Cubierto con lluvia escasa - "46n", # Cubierto con lluvia escasa (de noche) - }, - ATTR_CONDITION_SNOWY: { - "33", # Intervalos nubosos con nieve - "33n", # Intervalos nubosos con nieve (de noche) - "34", # Nuboso con nieve - "34n", # Nuboso con nieve (de noche) - "35", # Muy nuboso con nieve - "35n", # Muy nuboso con nieve (de noche) - "36", # Cubierto con nieve - "36n", # Cubierto con nieve (de noche) - "71", # Intervalos nubosos con nieve escasa - "71n", # Intervalos nubosos con nieve escasa (de noche) - "72", # Nuboso con nieve escasa - "72n", # Nuboso con nieve escasa (de noche) - "73", # Muy nuboso con nieve escasa - "73n", # Muy nuboso con nieve escasa (de noche) - "74", # Cubierto con nieve escasa - "74n", # Cubierto con nieve escasa (de noche) - }, - ATTR_CONDITION_SUNNY: { - "11", # Despejado - }, + AOD_COND_CLEAR_NIGHT: ATTR_CONDITION_CLEAR_NIGHT, + AOD_COND_CLOUDY: ATTR_CONDITION_CLOUDY, + AOD_COND_FOG: ATTR_CONDITION_FOG, + AOD_COND_LIGHTNING: ATTR_CONDITION_LIGHTNING, + AOD_COND_LIGHTNING_RAINY: ATTR_CONDITION_LIGHTNING_RAINY, + AOD_COND_PARTLY_CLODUY: ATTR_CONDITION_PARTLYCLOUDY, + AOD_COND_POURING: ATTR_CONDITION_POURING, + AOD_COND_RAINY: ATTR_CONDITION_RAINY, + AOD_COND_SNOWY: ATTR_CONDITION_SNOWY, + AOD_COND_SUNNY: ATTR_CONDITION_SUNNY, } FORECAST_MONITORED_CONDITIONS = [ @@ -187,16 +122,3 @@ FORECAST_MODE_DAILY: ATTR_API_FORECAST_DAILY, FORECAST_MODE_HOURLY: ATTR_API_FORECAST_HOURLY, } - - -WIND_BEARING_MAP = { - "C": None, - "N": 0.0, - "NE": 45.0, - "E": 90.0, - "SE": 135.0, - "S": 180.0, - "SO": 225.0, - "O": 270.0, - "NO": 315.0, -} diff --git a/homeassistant/components/aemet/weather_update_coordinator.py b/homeassistant/components/aemet/weather_update_coordinator.py index c6e27374f8f181..01c2502fb37aa0 100644 --- a/homeassistant/components/aemet/weather_update_coordinator.py +++ b/homeassistant/components/aemet/weather_update_coordinator.py @@ -34,6 +34,7 @@ ATTR_DATA, ) from aemet_opendata.exceptions import AemetError +from aemet_opendata.forecast import ForecastValue from aemet_opendata.helpers import ( get_forecast_day_value, get_forecast_hour_value, @@ -78,7 +79,6 @@ ATTR_API_WIND_SPEED, CONDITIONS_MAP, DOMAIN, - WIND_BEARING_MAP, ) _LOGGER = logging.getLogger(__name__) @@ -90,11 +90,8 @@ def format_condition(condition: str) -> str: """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - _LOGGER.error('Condition "%s" not found in CONDITIONS_MAP', condition) - return condition + val = ForecastValue.parse_condition(condition) + return CONDITIONS_MAP.get(val, val) def format_float(value) -> float | None: @@ -489,10 +486,7 @@ def _get_wind_bearing(day_data, hour): val = get_forecast_hour_value( day_data[AEMET_ATTR_WIND_GUST], hour, key=AEMET_ATTR_DIRECTION )[0] - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_bearing_day(day_data): @@ -500,10 +494,7 @@ def _get_wind_bearing_day(day_data): val = get_forecast_day_value( day_data[AEMET_ATTR_WIND], key=AEMET_ATTR_DIRECTION ) - if val in WIND_BEARING_MAP: - return WIND_BEARING_MAP[val] - _LOGGER.error("%s not found in Wind Bearing map", val) - return None + return ForecastValue.parse_wind_direction(val) @staticmethod def _get_wind_max_speed(day_data, hour): From 4779cdf2aeb80678c82767abb3e9c30cdca02074 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 11 Sep 2023 23:06:06 +0200 Subject: [PATCH 357/640] Let the discovergy config flow test end with create entry (#100153) --- .../components/discovergy/test_config_flow.py | 24 ++++++++++++++----- 1 file changed, 18 insertions(+), 6 deletions(-) diff --git a/tests/components/discovergy/test_config_flow.py b/tests/components/discovergy/test_config_flow.py index 9665da65789fa9..08e9df06978fc1 100644 --- a/tests/components/discovergy/test_config_flow.py +++ b/tests/components/discovergy/test_config_flow.py @@ -11,6 +11,7 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry +from tests.components.discovergy.const import GET_METERS async def test_form(hass: HomeAssistant, mock_meters: Mock) -> None: @@ -86,14 +87,24 @@ async def test_reauth( async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> None: """Test to handle exceptions.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER} - ) - with patch( "pydiscovergy.Discovergy.meters", side_effect=error, ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_EMAIL: "test@example.com", + CONF_PASSWORD: "test-password", + }, + ) + + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": message} + + with patch("pydiscovergy.Discovergy.meters", return_value=GET_METERS): result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -102,5 +113,6 @@ async def test_form_fail(hass: HomeAssistant, error: Exception, message: str) -> }, ) - assert result["type"] == data_entry_flow.FlowResultType.FORM - assert result["errors"] == {"base": message} + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "test@example.com" + assert "errors" not in result From e0e05f95463b94025dbfde635ec45cb4b666b54e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Mon, 11 Sep 2023 23:06:21 +0200 Subject: [PATCH 358/640] Update frontend to 20230911.0 (#100139) --- 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 58de25fc03ddc7..6291e3a237e9c4 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==20230908.0"] + "requirements": ["home-assistant-frontend==20230911.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index c78d0343fb58b0..61d2b5d35a6672 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -22,7 +22,7 @@ ha-av==10.1.1 hass-nabucasa==0.70.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 home-assistant-intents==2023.8.2 httpx==0.24.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 672c882b507b14..d7b6461ff42516 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -998,7 +998,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f6351dc12b3ec1..c57e5a67fadf69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -784,7 +784,7 @@ hole==0.8.0 holidays==0.28 # homeassistant.components.frontend -home-assistant-frontend==20230908.0 +home-assistant-frontend==20230911.0 # homeassistant.components.conversation home-assistant-intents==2023.8.2 From e231da42e1076662505432250967d775988f2d46 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 11 Sep 2023 22:21:44 -0400 Subject: [PATCH 359/640] Handle disconnects in zwave_js repair flow (#99964) * Handle disconnects in zwave_js repair flow * Combine logic to reduce LoC * only check once --- homeassistant/components/zwave_js/repairs.py | 24 +++--- .../components/zwave_js/strings.json | 3 + tests/components/zwave_js/test_repairs.py | 74 ++++++++++++++++--- 3 files changed, 80 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/zwave_js/repairs.py b/homeassistant/components/zwave_js/repairs.py index 89f51dddb88e04..83ee0523a3b5c0 100644 --- a/homeassistant/components/zwave_js/repairs.py +++ b/homeassistant/components/zwave_js/repairs.py @@ -2,7 +2,6 @@ from __future__ import annotations import voluptuous as vol -from zwave_js_server.model.node import Node from homeassistant import data_entry_flow from homeassistant.components.repairs import ConfirmRepairFlow, RepairsFlow @@ -14,10 +13,10 @@ class DeviceConfigFileChangedFlow(RepairsFlow): """Handler for an issue fixing flow.""" - def __init__(self, node: Node, device_name: str) -> None: + def __init__(self, data: dict[str, str]) -> None: """Initialize.""" - self.node = node - self.device_name = device_name + self.device_name: str = data["device_name"] + self.device_id: str = data["device_id"] async def async_step_init( self, user_input: dict[str, str] | None = None @@ -30,7 +29,14 @@ async def async_step_confirm( ) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" if user_input is not None: - self.hass.async_create_task(self.node.async_refresh_info()) + try: + node = async_get_node_from_device_id(self.hass, self.device_id) + except ValueError: + return self.async_abort( + reason="cannot_connect", + description_placeholders={"device_name": self.device_name}, + ) + self.hass.async_create_task(node.async_refresh_info()) return self.async_create_entry(title="", data={}) return self.async_show_form( @@ -41,15 +47,11 @@ async def async_step_confirm( async def async_create_fix_flow( - hass: HomeAssistant, - issue_id: str, - data: dict[str, str] | None, + hass: HomeAssistant, issue_id: str, data: dict[str, str] | None ) -> RepairsFlow: """Create flow.""" if issue_id.split(".")[0] == "device_config_file_changed": assert data - return DeviceConfigFileChangedFlow( - async_get_node_from_device_id(hass, data["device_id"]), data["device_name"] - ) + return DeviceConfigFileChangedFlow(data) return ConfirmRepairFlow() diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index 6435c6b7a544d4..6994ce15a0ca38 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -170,6 +170,9 @@ "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." } + }, + "abort": { + "cannot_connect": "Cannot connect to {device_name}. Please try again later after confirming that your Z-Wave network is up and connected to Home Assistant." } } } diff --git a/tests/components/zwave_js/test_repairs.py b/tests/components/zwave_js/test_repairs.py index 07371a299efa41..d18bcfa09aa534 100644 --- a/tests/components/zwave_js/test_repairs.py +++ b/tests/components/zwave_js/test_repairs.py @@ -22,16 +22,10 @@ from tests.typing import ClientSessionGenerator, WebSocketGenerator -async def test_device_config_file_changed( - hass: HomeAssistant, - hass_client: ClientSessionGenerator, - hass_ws_client: WebSocketGenerator, - client, - multisensor_6_state, - integration, -) -> None: - """Test the device_config_file_changed issue.""" - dev_reg = dr.async_get(hass) +async def _trigger_repair_issue( + hass: HomeAssistant, client, multisensor_6_state +) -> Node: + """Trigger repair issue.""" # Create a node node_state = deepcopy(multisensor_6_state) node = Node(client, node_state) @@ -53,6 +47,23 @@ async def test_device_config_file_changed( client.async_send_command_no_wait.reset_mock() + return node + + +async def test_device_config_file_changed( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test the device_config_file_changed issue.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + client.async_send_command_no_wait.reset_mock() + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) assert device issue_id = f"device_config_file_changed.{device.id}" @@ -157,3 +168,46 @@ async def test_invalid_issue( msg = await ws_client.receive_json() assert msg["success"] assert len(msg["result"]["issues"]) == 0 + + +async def test_abort_confirm( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + hass_ws_client: WebSocketGenerator, + client, + multisensor_6_state, + integration, +) -> None: + """Test aborting device_config_file_changed issue in confirm step.""" + dev_reg = dr.async_get(hass) + node = await _trigger_repair_issue(hass, client, multisensor_6_state) + + device = dev_reg.async_get_device(identifiers={get_device_id(client.driver, node)}) + assert device + issue_id = f"device_config_file_changed.{device.id}" + + await async_process_repairs_platforms(hass) + await hass_ws_client(hass) + http_client = await hass_client() + + url = RepairsFlowIndexView.url + resp = await http_client.post(url, json={"handler": DOMAIN, "issue_id": issue_id}) + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data["step_id"] == "confirm" + + # Unload config entry so we can't connect to the node + await hass.config_entries.async_unload(integration.entry_id) + + # Apply fix + url = RepairsFlowResourceView.url.format(flow_id=flow_id) + resp = await http_client.post(url) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + assert data["type"] == "abort" + assert data["reason"] == "cannot_connect" + assert data["description_placeholders"] == {"device_name": device.name} From 15b9963a24bdd08efcb3afc3785426762cd6f441 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 12 Sep 2023 04:23:55 +0200 Subject: [PATCH 360/640] Bump ZHA dependencies (#100156) --- 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 cce223fac1100b..c3fa6b1ff01f02 100644 --- a/homeassistant/components/zha/manifest.json +++ b/homeassistant/components/zha/manifest.json @@ -25,9 +25,9 @@ "pyserial==3.5", "pyserial-asyncio==0.6", "zha-quirks==0.0.103", - "zigpy-deconz==0.21.0", + "zigpy-deconz==0.21.1", "zigpy==0.57.1", - "zigpy-xbee==0.18.1", + "zigpy-xbee==0.18.2", "zigpy-zigate==0.11.0", "zigpy-znp==0.11.4", "universal-silabs-flasher==0.0.13" diff --git a/requirements_all.txt b/requirements_all.txt index d7b6461ff42516..d998da2b537777 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2784,10 +2784,10 @@ zhong-hong-hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c57e5a67fadf69..ce2d15a5632d00 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2054,10 +2054,10 @@ zeversolar==0.3.1 zha-quirks==0.0.103 # homeassistant.components.zha -zigpy-deconz==0.21.0 +zigpy-deconz==0.21.1 # homeassistant.components.zha -zigpy-xbee==0.18.1 +zigpy-xbee==0.18.2 # homeassistant.components.zha zigpy-zigate==0.11.0 From 3d28c6d6369e91d1ea1abd3ff16f2bb4c356b476 Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Tue, 12 Sep 2023 04:30:50 +0200 Subject: [PATCH 361/640] Fix AVM Fritz!Tools update entity (#100151) * move update entity to coordinator * fix tests --- homeassistant/components/fritz/common.py | 11 ++++-- homeassistant/components/fritz/update.py | 38 ++++++++++++------ tests/components/fritz/test_update.py | 49 +++++++++++++++--------- 3 files changed, 63 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 6977377812160b..76368175ca0ded 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -1096,7 +1096,7 @@ def device_info(self) -> DeviceInfo: class FritzRequireKeysMixin: """Fritz entity description mix in.""" - value_fn: Callable[[FritzStatus, Any], Any] + value_fn: Callable[[FritzStatus, Any], Any] | None @dataclass @@ -1118,9 +1118,12 @@ def __init__( ) -> None: """Init device info class.""" super().__init__(avm_wrapper) - self.async_on_remove( - avm_wrapper.register_entity_updates(description.key, description.value_fn) - ) + if description.value_fn is not None: + self.async_on_remove( + avm_wrapper.register_entity_updates( + description.key, description.value_fn + ) + ) self.entity_description = description self._device_name = device_name self._attr_unique_id = f"{avm_wrapper.unique_id}-{description.key}" diff --git a/homeassistant/components/fritz/update.py b/homeassistant/components/fritz/update.py index 03cffc3cae6441..80cbe1f4c5c1d6 100644 --- a/homeassistant/components/fritz/update.py +++ b/homeassistant/components/fritz/update.py @@ -1,20 +1,31 @@ """Support for AVM FRITZ!Box update platform.""" from __future__ import annotations +from dataclasses import dataclass import logging from typing import Any -from homeassistant.components.update import UpdateEntity, UpdateEntityFeature +from homeassistant.components.update import ( + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .common import AvmWrapper, FritzBoxBaseEntity +from .common import AvmWrapper, FritzBoxBaseCoordinatorEntity, FritzEntityDescription from .const import DOMAIN _LOGGER = logging.getLogger(__name__) +@dataclass +class FritzUpdateEntityDescription(UpdateEntityDescription, FritzEntityDescription): + """Describes Fritz update entity.""" + + async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: @@ -27,11 +38,13 @@ async def async_setup_entry( async_add_entities(entities) -class FritzBoxUpdateEntity(FritzBoxBaseEntity, UpdateEntity): +class FritzBoxUpdateEntity(FritzBoxBaseCoordinatorEntity, UpdateEntity): """Mixin for update entity specific attributes.""" + _attr_entity_category = EntityCategory.CONFIG _attr_supported_features = UpdateEntityFeature.INSTALL _attr_title = "FRITZ!OS" + entity_description: FritzUpdateEntityDescription def __init__( self, @@ -39,29 +52,30 @@ def __init__( device_friendly_name: str, ) -> None: """Init FRITZ!Box connectivity class.""" - self._attr_name = f"{device_friendly_name} FRITZ!OS" - self._attr_unique_id = f"{avm_wrapper.unique_id}-update" - super().__init__(avm_wrapper, device_friendly_name) + description = FritzUpdateEntityDescription( + key="update", name="FRITZ!OS", value_fn=None + ) + super().__init__(avm_wrapper, device_friendly_name, description) @property def installed_version(self) -> str | None: """Version currently in use.""" - return self._avm_wrapper.current_firmware + return self.coordinator.current_firmware @property def latest_version(self) -> str | None: """Latest version available for install.""" - if self._avm_wrapper.update_available: - return self._avm_wrapper.latest_firmware - return self._avm_wrapper.current_firmware + if self.coordinator.update_available: + return self.coordinator.latest_firmware + return self.coordinator.current_firmware @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - return self._avm_wrapper.release_url + return self.coordinator.release_url async def async_install( self, version: str | None, backup: bool, **kwargs: Any ) -> None: """Install an update.""" - await self._avm_wrapper.async_trigger_firmware_update() + await self.coordinator.async_trigger_firmware_update() diff --git a/tests/components/fritz/test_update.py b/tests/components/fritz/test_update.py index dbff4713553c21..bc677e28ebe099 100644 --- a/tests/components/fritz/test_update.py +++ b/tests/components/fritz/test_update.py @@ -8,11 +8,25 @@ from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from .const import MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL, MOCK_USER_DATA +from .const import ( + MOCK_FB_SERVICES, + MOCK_FIRMWARE_AVAILABLE, + MOCK_FIRMWARE_RELEASE_URL, + MOCK_USER_DATA, +) from tests.common import MockConfigEntry from tests.typing import ClientSessionGenerator +AVAILABLE_UPDATE = { + "UserInterface1": { + "GetInfo": { + "NewX_AVM-DE_Version": MOCK_FIRMWARE_AVAILABLE, + "NewX_AVM-DE_InfoURL": MOCK_FIRMWARE_RELEASE_URL, + }, + } +} + async def test_update_entities_initialized( hass: HomeAssistant, @@ -41,23 +55,21 @@ async def test_update_available( ) -> None: """Test update entities.""" - with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ): - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) - entry.add_to_hass(hass) + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() - assert entry.state == ConfigEntryState.LOADED + entry = MockConfigEntry(domain=DOMAIN, data=MOCK_USER_DATA) + entry.add_to_hass(hass) - update = hass.states.get("update.mock_title_fritz_os") - assert update is not None - assert update.state == "on" - assert update.attributes.get("installed_version") == "7.29" - assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE - assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + update = hass.states.get("update.mock_title_fritz_os") + assert update is not None + assert update.state == "on" + assert update.attributes.get("installed_version") == "7.29" + assert update.attributes.get("latest_version") == MOCK_FIRMWARE_AVAILABLE + assert update.attributes.get("release_url") == MOCK_FIRMWARE_RELEASE_URL async def test_no_update_available( @@ -90,10 +102,9 @@ async def test_available_update_can_be_installed( ) -> None: """Test update entities.""" + fc_class_mock().override_services({**MOCK_FB_SERVICES, **AVAILABLE_UPDATE}) + with patch( - "homeassistant.components.fritz.common.FritzBoxTools._update_device_info", - return_value=(True, MOCK_FIRMWARE_AVAILABLE, MOCK_FIRMWARE_RELEASE_URL), - ), patch( "homeassistant.components.fritz.common.FritzBoxTools.async_trigger_firmware_update", return_value=True, ) as mocked_update_call: From a20d1a357fbe546102f6876b990b6ff03fa7b17c Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 21:34:23 -0500 Subject: [PATCH 362/640] Avoid probing ipp printers for unique_id when it is available via mdns (#99982) * Avoid probing ipp printers for unique_id when it is available via mdns We would always probe the device in the ipp flow and than abort if it was already configured. We avoid the probe for most printers. * dry * coverage * fix test * add test for updating host --- homeassistant/components/ipp/config_flow.py | 36 ++++-- .../ipp/fixtures/printer_without_uuid.json | 35 ++++++ tests/components/ipp/test_config_flow.py | 103 +++++++++++++++++- 3 files changed, 160 insertions(+), 14 deletions(-) create mode 100644 tests/components/ipp/fixtures/printer_without_uuid.json diff --git a/homeassistant/components/ipp/config_flow.py b/homeassistant/components/ipp/config_flow.py index 8d1da6eca91adb..dfe6c0b2127f19 100644 --- a/homeassistant/components/ipp/config_flow.py +++ b/homeassistant/components/ipp/config_flow.py @@ -116,8 +116,7 @@ async def async_step_zeroconf( name = discovery_info.name.replace(f".{zctype}", "") tls = zctype == "_ipps._tcp.local." base_path = discovery_info.properties.get("rp", "ipp/print") - - self.context.update({"title_placeholders": {"name": name}}) + unique_id = discovery_info.properties.get("UUID") self.discovery_info.update( { @@ -127,10 +126,18 @@ async def async_step_zeroconf( CONF_VERIFY_SSL: False, CONF_BASE_PATH: f"/{base_path}", CONF_NAME: name, - CONF_UUID: discovery_info.properties.get("UUID"), + CONF_UUID: unique_id, } ) + if unique_id: + # If we already have the unique id, try to set it now + # so we can avoid probing the device if its already + # configured or ignored + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) + + self.context.update({"title_placeholders": {"name": name}}) + try: info = await validate_input(self.hass, self.discovery_info) except IPPConnectionUpgradeRequired: @@ -147,7 +154,6 @@ async def async_step_zeroconf( _LOGGER.debug("IPP Error", exc_info=True) return self.async_abort(reason="ipp_error") - unique_id = self.discovery_info[CONF_UUID] if not unique_id and info[CONF_UUID]: _LOGGER.debug( "Printer UUID is missing from discovery info. Falling back to IPP UUID" @@ -164,18 +170,24 @@ async def async_step_zeroconf( "Unable to determine unique id from discovery info and IPP response" ) - if unique_id: - await self.async_set_unique_id(unique_id) - self._abort_if_unique_id_configured( - updates={ - CONF_HOST: self.discovery_info[CONF_HOST], - CONF_NAME: self.discovery_info[CONF_NAME], - }, - ) + if unique_id and self.unique_id != unique_id: + await self._async_set_unique_id_and_abort_if_already_configured(unique_id) await self._async_handle_discovery_without_unique_id() return await self.async_step_zeroconf_confirm() + async def _async_set_unique_id_and_abort_if_already_configured( + self, unique_id: str + ) -> None: + """Set the unique ID and abort if already configured.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured( + updates={ + CONF_HOST: self.discovery_info[CONF_HOST], + CONF_NAME: self.discovery_info[CONF_NAME], + }, + ) + async def async_step_zeroconf_confirm( self, user_input: dict[str, Any] | None = None ) -> FlowResult: diff --git a/tests/components/ipp/fixtures/printer_without_uuid.json b/tests/components/ipp/fixtures/printer_without_uuid.json new file mode 100644 index 00000000000000..21f1eb93a32ba9 --- /dev/null +++ b/tests/components/ipp/fixtures/printer_without_uuid.json @@ -0,0 +1,35 @@ +{ + "printer-state": "idle", + "printer-name": "Test Printer", + "printer-location": null, + "printer-make-and-model": "Test HA-1000 Series", + "printer-device-id": "MFG:TEST;CMD:ESCPL2,BDC,D4,D4PX,ESCPR7,END4,GENEP,URF;MDL:HA-1000 Series;CLS:PRINTER;DES:TEST HA-1000 Series;CID:EpsonRGB;FID:FXN,DPA,WFA,ETN,AFN,DAN,WRA;RID:20;DDS:022500;ELG:1000;SN:555534593035345555;URF:CP1,PQ4-5,OB9,OFU0,RS360,SRGB24,W8,DM3,IS1-7-6,V1.4,MT1-3-7-8-10-11-12;", + "printer-uri-supported": [ + "ipps://192.168.1.31:631/ipp/print", + "ipp://192.168.1.31:631/ipp/print" + ], + "uri-authentication-supported": ["none", "none"], + "uri-security-supported": ["tls", "none"], + "printer-info": "Test HA-1000 Series", + "printer-up-time": 30, + "printer-firmware-string-version": "20.23.06HA", + "printer-more-info": "http://192.168.1.31:80/PRESENTATION/BONJOUR", + "marker-names": [ + "Black ink", + "Photo black ink", + "Cyan ink", + "Yellow ink", + "Magenta ink" + ], + "marker-types": [ + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge", + "ink-cartridge" + ], + "marker-colors": ["#000000", "#000000", "#00FFFF", "#FFFF00", "#FF00FF"], + "marker-levels": [58, 98, 91, 95, 73], + "marker-low-levels": [10, 10, 10, 10, 10], + "marker-high-levels": [100, 100, 100, 100, 100] +} diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 69a2bb9287a8c1..0daf8a0f7e0f33 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for the IPP config flow.""" import dataclasses -from unittest.mock import MagicMock +import json +from unittest.mock import MagicMock, patch from pyipp import ( IPPConnectionError, @@ -8,6 +9,7 @@ IPPError, IPPParseError, IPPVersionNotSupportedError, + Printer, ) import pytest @@ -23,7 +25,7 @@ MOCK_ZEROCONF_IPPS_SERVICE_INFO, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, load_fixture pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -316,6 +318,31 @@ async def test_zeroconf_with_uuid_device_exists_abort( assert result["reason"] == "already_configured" +async def test_zeroconf_with_uuid_device_exists_abort_new_host( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_ipp_config_flow: MagicMock, +) -> None: + """Test we abort zeroconf flow if printer already configured.""" + mock_config_entry.add_to_hass(hass) + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO, host="1.2.3.9") + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + assert mock_config_entry.data[CONF_HOST] == "1.2.3.9" + + async def test_zeroconf_empty_unique_id( hass: HomeAssistant, mock_ipp_config_flow: MagicMock, @@ -337,6 +364,21 @@ async def test_zeroconf_empty_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_zeroconf_no_unique_id( hass: HomeAssistant, @@ -355,6 +397,21 @@ async def test_zeroconf_no_unique_id( assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "cfe92100-67c4-11d4-a45f-f8d027761251" + + assert result["result"] + assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + async def test_full_user_flow_implementation( hass: HomeAssistant, @@ -448,3 +505,45 @@ async def test_full_zeroconf_tls_flow_implementation( assert result["result"] assert result["result"].unique_id == "cfe92100-67c4-11d4-a45f-f8d027761251" + + +async def test_zeroconf_empty_unique_id_uses_serial(hass: HomeAssistant) -> None: + """Test zeroconf flow if printer lacks (empty) unique identification with serial fallback.""" + fixture = await hass.async_add_executor_job( + load_fixture, "ipp/printer_without_uuid.json" + ) + mock_printer_without_uuid = Printer.from_dict(json.loads(fixture)) + mock_printer_without_uuid.unique_id = None + + discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO) + discovery_info.properties = { + **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, + "UUID": "", + } + with patch( + "homeassistant.components.ipp.config_flow.IPP", autospec=True + ) as ipp_mock: + client = ipp_mock.return_value + client.printer.return_value = mock_printer_without_uuid + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_HOST: "192.168.1.31", CONF_BASE_PATH: "/ipp/print"}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "EPSON XP-6000 Series" + + assert result["data"] + assert result["data"][CONF_HOST] == "192.168.1.31" + assert result["data"][CONF_UUID] == "" + + assert result["result"] + assert result["result"].unique_id == "555534593035345555" From 140af44e315bc0ea7ce8e36db90a90c218899d69 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 11 Sep 2023 21:40:32 -0500 Subject: [PATCH 363/640] Bump dbus-fast to 2.4.0 (#100158) --- 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 8cc2a7adb65732..762117052f05f0 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.11.0", - "dbus-fast==2.2.0" + "dbus-fast==2.4.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 61d2b5d35a6672..fa9fade40317df 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.2.0 +dbus-fast==2.4.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index d998da2b537777..0b73a9d4bc7b2f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.2.0 +dbus-fast==2.4.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce2d15a5632d00..851582ee7012e6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.2.0 +dbus-fast==2.4.0 # homeassistant.components.debugpy debugpy==1.6.7 From 5d46e225918a225424b44532518205bc33266068 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 12 Sep 2023 04:52:02 +0200 Subject: [PATCH 364/640] Move airly coordinator to its own file (#99545) --- homeassistant/components/airly/__init__.py | 123 +---------------- homeassistant/components/airly/coordinator.py | 126 ++++++++++++++++++ tests/components/airly/test_init.py | 2 +- 3 files changed, 130 insertions(+), 121 deletions(-) create mode 100644 homeassistant/components/airly/coordinator.py diff --git a/homeassistant/components/airly/__init__.py b/homeassistant/components/airly/__init__.py index 982687c7723ec6..91208de519b1da 100644 --- a/homeassistant/components/airly/__init__.py +++ b/homeassistant/components/airly/__init__.py @@ -1,15 +1,8 @@ """The Airly integration.""" from __future__ import annotations -from asyncio import timeout from datetime import timedelta import logging -from math import ceil - -from aiohttp import ClientSession -from aiohttp.client_exceptions import ClientConnectorError -from airly import Airly -from airly.exceptions import AirlyError from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM from homeassistant.config_entries import ConfigEntry @@ -17,53 +10,15 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er 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 - -from .const import ( - ATTR_API_ADVICE, - ATTR_API_CAQI, - ATTR_API_CAQI_DESCRIPTION, - ATTR_API_CAQI_LEVEL, - CONF_USE_NEAREST, - DOMAIN, - MAX_UPDATE_INTERVAL, - MIN_UPDATE_INTERVAL, - NO_AIRLY_SENSORS, -) + +from .const import CONF_USE_NEAREST, DOMAIN, MIN_UPDATE_INTERVAL +from .coordinator import AirlyDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: - """Return data update interval. - - The number of requests is reset at midnight UTC so we calculate the update - interval based on number of minutes until midnight, the number of Airly instances - and the number of remaining requests. - """ - now = dt_util.utcnow() - midnight = dt_util.find_next_time_expression_time( - now, seconds=[0], minutes=[0], hours=[0] - ) - minutes_to_midnight = (midnight - now).total_seconds() / 60 - interval = timedelta( - minutes=min( - max( - ceil(minutes_to_midnight / requests_remaining * instances_count), - MIN_UPDATE_INTERVAL, - ), - MAX_UPDATE_INTERVAL, - ) - ) - - _LOGGER.debug("Data will be update every %s", interval) - - return interval - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Airly as config entry.""" api_key = entry.data[CONF_API_KEY] @@ -131,75 +86,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirlyDataUpdateCoordinator(DataUpdateCoordinator): - """Define an object to hold Airly data.""" - - def __init__( - self, - hass: HomeAssistant, - session: ClientSession, - api_key: str, - latitude: float, - longitude: float, - update_interval: timedelta, - use_nearest: bool, - ) -> None: - """Initialize.""" - self.latitude = latitude - self.longitude = longitude - # Currently, Airly only supports Polish and English - language = "pl" if hass.config.language == "pl" else "en" - self.airly = Airly(api_key, session, language=language) - self.use_nearest = use_nearest - - super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _async_update_data(self) -> dict[str, str | float | int]: - """Update data via library.""" - data: dict[str, str | float | int] = {} - if self.use_nearest: - measurements = self.airly.create_measurements_session_nearest( - self.latitude, self.longitude, max_distance_km=5 - ) - else: - measurements = self.airly.create_measurements_session_point( - self.latitude, self.longitude - ) - async with timeout(20): - try: - await measurements.update() - except (AirlyError, ClientConnectorError) as error: - raise UpdateFailed(error) from error - - _LOGGER.debug( - "Requests remaining: %s/%s", - self.airly.requests_remaining, - self.airly.requests_per_day, - ) - - # Airly API sometimes returns None for requests remaining so we update - # update_interval only if we have valid value. - if self.airly.requests_remaining: - self.update_interval = set_update_interval( - len(self.hass.config_entries.async_entries(DOMAIN)), - self.airly.requests_remaining, - ) - - values = measurements.current["values"] - index = measurements.current["indexes"][0] - standards = measurements.current["standards"] - - if index["description"] == NO_AIRLY_SENSORS: - raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") - for value in values: - data[value["name"]] = value["value"] - for standard in standards: - data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] - data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] - data[ATTR_API_CAQI] = index["value"] - data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") - data[ATTR_API_CAQI_DESCRIPTION] = index["description"] - data[ATTR_API_ADVICE] = index["advice"] - return data diff --git a/homeassistant/components/airly/coordinator.py b/homeassistant/components/airly/coordinator.py new file mode 100644 index 00000000000000..9f2a1c965114f8 --- /dev/null +++ b/homeassistant/components/airly/coordinator.py @@ -0,0 +1,126 @@ +"""DataUpdateCoordinator for the Airly integration.""" +from asyncio import timeout +from datetime import timedelta +import logging +from math import ceil + +from aiohttp import ClientSession +from aiohttp.client_exceptions import ClientConnectorError +from airly import Airly +from airly.exceptions import AirlyError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util import dt as dt_util + +from .const import ( + ATTR_API_ADVICE, + ATTR_API_CAQI, + ATTR_API_CAQI_DESCRIPTION, + ATTR_API_CAQI_LEVEL, + DOMAIN, + MAX_UPDATE_INTERVAL, + MIN_UPDATE_INTERVAL, + NO_AIRLY_SENSORS, +) + +_LOGGER = logging.getLogger(__name__) + + +def set_update_interval(instances_count: int, requests_remaining: int) -> timedelta: + """Return data update interval. + + The number of requests is reset at midnight UTC so we calculate the update + interval based on number of minutes until midnight, the number of Airly instances + and the number of remaining requests. + """ + now = dt_util.utcnow() + midnight = dt_util.find_next_time_expression_time( + now, seconds=[0], minutes=[0], hours=[0] + ) + minutes_to_midnight = (midnight - now).total_seconds() / 60 + interval = timedelta( + minutes=min( + max( + ceil(minutes_to_midnight / requests_remaining * instances_count), + MIN_UPDATE_INTERVAL, + ), + MAX_UPDATE_INTERVAL, + ) + ) + + _LOGGER.debug("Data will be update every %s", interval) + + return interval + + +class AirlyDataUpdateCoordinator(DataUpdateCoordinator): + """Define an object to hold Airly data.""" + + def __init__( + self, + hass: HomeAssistant, + session: ClientSession, + api_key: str, + latitude: float, + longitude: float, + update_interval: timedelta, + use_nearest: bool, + ) -> None: + """Initialize.""" + self.latitude = latitude + self.longitude = longitude + # Currently, Airly only supports Polish and English + language = "pl" if hass.config.language == "pl" else "en" + self.airly = Airly(api_key, session, language=language) + self.use_nearest = use_nearest + + super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _async_update_data(self) -> dict[str, str | float | int]: + """Update data via library.""" + data: dict[str, str | float | int] = {} + if self.use_nearest: + measurements = self.airly.create_measurements_session_nearest( + self.latitude, self.longitude, max_distance_km=5 + ) + else: + measurements = self.airly.create_measurements_session_point( + self.latitude, self.longitude + ) + async with timeout(20): + try: + await measurements.update() + except (AirlyError, ClientConnectorError) as error: + raise UpdateFailed(error) from error + + _LOGGER.debug( + "Requests remaining: %s/%s", + self.airly.requests_remaining, + self.airly.requests_per_day, + ) + + # Airly API sometimes returns None for requests remaining so we update + # update_interval only if we have valid value. + if self.airly.requests_remaining: + self.update_interval = set_update_interval( + len(self.hass.config_entries.async_entries(DOMAIN)), + self.airly.requests_remaining, + ) + + values = measurements.current["values"] + index = measurements.current["indexes"][0] + standards = measurements.current["standards"] + + if index["description"] == NO_AIRLY_SENSORS: + raise UpdateFailed("Can't retrieve data: no Airly sensors in this area") + for value in values: + data[value["name"]] = value["value"] + for standard in standards: + data[f"{standard['pollutant']}_LIMIT"] = standard["limit"] + data[f"{standard['pollutant']}_PERCENT"] = standard["percent"] + data[ATTR_API_CAQI] = index["value"] + data[ATTR_API_CAQI_LEVEL] = index["level"].lower().replace("_", " ") + data[ATTR_API_CAQI_DESCRIPTION] = index["description"] + data[ATTR_API_ADVICE] = index["advice"] + return data diff --git a/tests/components/airly/test_init.py b/tests/components/airly/test_init.py index 9b69607e6aa492..0a3ea927446b8a 100644 --- a/tests/components/airly/test_init.py +++ b/tests/components/airly/test_init.py @@ -5,8 +5,8 @@ import pytest from homeassistant.components.air_quality import DOMAIN as AIR_QUALITY_PLATFORM -from homeassistant.components.airly import set_update_interval from homeassistant.components.airly.const import DOMAIN +from homeassistant.components.airly.coordinator import set_update_interval from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant From 8e43f79f19538de4d2016396adcc3087664043bf Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Mon, 11 Sep 2023 23:03:47 -0400 Subject: [PATCH 365/640] Bump zwave-js-server-python to 0.51.2 (#100159) --- 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 080074451bda69..4ea46099f14154 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.51.1"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.2"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 0b73a9d4bc7b2f..eaeab3d151a13e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2802,7 +2802,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.1 +zwave-js-server-python==0.51.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 851582ee7012e6..d81c6ecb22bcfa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2069,7 +2069,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.1 +zwave-js-server-python==0.51.2 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From 183b77973f5f1e0f84a7f94942594a2bebfde90e Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Mon, 11 Sep 2023 22:56:08 -0700 Subject: [PATCH 366/640] Add configuration flow to Todoist integration (#100094) * Add config flow to todoist * Fix service calls for todoist * Fix configuration entry test setup * Bump test coverage to 100% * Apply pr feedback --- homeassistant/components/todoist/__init__.py | 45 +++++- homeassistant/components/todoist/calendar.py | 48 ++++++- .../components/todoist/config_flow.py | 63 ++++++++ .../components/todoist/coordinator.py | 18 ++- .../components/todoist/manifest.json | 1 + homeassistant/components/todoist/strings.json | 19 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/todoist/conftest.py | 135 ++++++++++++++++++ tests/components/todoist/test_calendar.py | 115 ++++++--------- tests/components/todoist/test_config_flow.py | 123 ++++++++++++++++ tests/components/todoist/test_init.py | 47 ++++++ 12 files changed, 540 insertions(+), 77 deletions(-) create mode 100644 homeassistant/components/todoist/config_flow.py create mode 100644 tests/components/todoist/conftest.py create mode 100644 tests/components/todoist/test_config_flow.py create mode 100644 tests/components/todoist/test_init.py diff --git a/homeassistant/components/todoist/__init__.py b/homeassistant/components/todoist/__init__.py index 78a9cb89624664..12b75a40bae256 100644 --- a/homeassistant/components/todoist/__init__.py +++ b/homeassistant/components/todoist/__init__.py @@ -1 +1,44 @@ -"""The todoist component.""" +"""The todoist integration.""" + +import datetime +import logging + +from todoist_api_python.api_async import TodoistAPIAsync + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_TOKEN, Platform +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .coordinator import TodoistCoordinator + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = datetime.timedelta(minutes=1) + + +PLATFORMS: list[Platform] = [Platform.CALENDAR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up todoist from a config entry.""" + + token = entry.data[CONF_TOKEN] + api = TodoistAPIAsync(token) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = 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/todoist/calendar.py b/homeassistant/components/todoist/calendar.py index 544144018dd9d6..40ceb71ee5fd48 100644 --- a/homeassistant/components/todoist/calendar.py +++ b/homeassistant/components/todoist/calendar.py @@ -17,8 +17,10 @@ CalendarEntity, CalendarEvent, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, CONF_NAME, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, ServiceCall, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -106,6 +108,23 @@ SCAN_INTERVAL = timedelta(minutes=1) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Todoist calendar platform config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + projects = await coordinator.async_get_projects() + labels = await coordinator.async_get_labels() + + entities = [] + for project in projects: + project_data: ProjectData = {CONF_NAME: project.name, CONF_ID: project.id} + entities.append(TodoistProjectEntity(coordinator, project_data, labels)) + + async_add_entities(entities) + async_register_services(hass, coordinator) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -119,7 +138,7 @@ async def async_setup_platform( project_id_lookup = {} api = TodoistAPIAsync(token) - coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api) + coordinator = TodoistCoordinator(hass, _LOGGER, SCAN_INTERVAL, api, token) await coordinator.async_refresh() async def _shutdown_coordinator(_: Event) -> None: @@ -177,12 +196,29 @@ async def _shutdown_coordinator(_: Event) -> None: async_add_entities(project_devices, update_before_add=True) + async_register_services(hass, coordinator) + + +def async_register_services( + hass: HomeAssistant, coordinator: TodoistCoordinator +) -> None: + """Register services.""" + + if hass.services.has_service(DOMAIN, SERVICE_NEW_TASK): + return + session = async_get_clientsession(hass) async def handle_new_task(call: ServiceCall) -> None: """Call when a user creates a new Todoist Task from Home Assistant.""" - project_name = call.data[PROJECT_NAME] - project_id = project_id_lookup[project_name] + project_name = call.data[PROJECT_NAME].lower() + projects = await coordinator.async_get_projects() + project_id: str | None = None + for project in projects: + if project_name == project.name.lower(): + project_id = project.id + if project_id is None: + raise HomeAssistantError(f"Invalid project name '{project_name}'") # Create the task content = call.data[CONTENT] @@ -192,7 +228,7 @@ async def handle_new_task(call: ServiceCall) -> None: data["labels"] = task_labels if ASSIGNEE in call.data: - collaborators = await api.get_collaborators(project_id) + collaborators = await coordinator.api.get_collaborators(project_id) collaborator_id_lookup = { collab.name.lower(): collab.id for collab in collaborators } @@ -225,7 +261,7 @@ async def handle_new_task(call: ServiceCall) -> None: date_format = "%Y-%m-%dT%H:%M:%S" data["due_datetime"] = datetime.strftime(due_date, date_format) - api_task = await api.add_task(content, **data) + api_task = await coordinator.api.add_task(content, **data) # @NOTE: The rest-api doesn't support reminders, this works manually using # the sync api, in order to keep functional parity with the component. @@ -263,7 +299,7 @@ async def add_reminder(reminder_due: dict): } ] } - headers = create_headers(token=token, with_content=True) + headers = create_headers(token=coordinator.token, with_content=True) return await session.post(sync_url, headers=headers, json=reminder_data) if _reminder_due: diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py new file mode 100644 index 00000000000000..0a41ecb0463d9a --- /dev/null +++ b/homeassistant/components/todoist/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for todoist integration.""" + +from http import HTTPStatus +import logging +from typing import Any + +from requests.exceptions import HTTPError +from todoist_api_python.api_async import TodoistAPIAsync +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SETTINGS_URL = "https://todoist.com/app/settings/integrations" + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_TOKEN): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for todoist.""" + + VERSION = 1 + + async def async_step_user( + 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: + api = TodoistAPIAsync(user_input[CONF_TOKEN]) + try: + await api.get_tasks() + except HTTPError as err: + if err.response.status_code == HTTPStatus.UNAUTHORIZED: + errors["base"] = "invalid_access_token" + else: + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_TOKEN]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title="Todoist", data=user_input) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + description_placeholders={"settings_url": SETTINGS_URL}, + ) diff --git a/homeassistant/components/todoist/coordinator.py b/homeassistant/components/todoist/coordinator.py index b573d1d11277cb..702c43883ea41c 100644 --- a/homeassistant/components/todoist/coordinator.py +++ b/homeassistant/components/todoist/coordinator.py @@ -3,7 +3,7 @@ import logging from todoist_api_python.api_async import TodoistAPIAsync -from todoist_api_python.models import Task +from todoist_api_python.models import Label, Project, Task from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,10 +18,14 @@ def __init__( logger: logging.Logger, update_interval: timedelta, api: TodoistAPIAsync, + token: str, ) -> None: """Initialize the Todoist coordinator.""" super().__init__(hass, logger, name="Todoist", update_interval=update_interval) self.api = api + self._projects: list[Project] | None = None + self._labels: list[Label] | None = None + self.token = token async def _async_update_data(self) -> list[Task]: """Fetch tasks from the Todoist API.""" @@ -29,3 +33,15 @@ async def _async_update_data(self) -> list[Task]: return await self.api.get_tasks() except Exception as err: raise UpdateFailed(f"Error communicating with API: {err}") from err + + async def async_get_projects(self) -> list[Project]: + """Return todoist projects fetched at most once.""" + if self._projects is None: + self._projects = await self.api.get_projects() + return self._projects + + async def async_get_labels(self) -> list[Label]: + """Return todoist labels fetched at most once.""" + if self._labels is None: + self._labels = await self.api.get_labels() + return self._labels diff --git a/homeassistant/components/todoist/manifest.json b/homeassistant/components/todoist/manifest.json index a83cdbe1b09fd3..72d76108353f13 100644 --- a/homeassistant/components/todoist/manifest.json +++ b/homeassistant/components/todoist/manifest.json @@ -2,6 +2,7 @@ "domain": "todoist", "name": "Todoist", "codeowners": ["@boralyl"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/todoist", "iot_class": "cloud_polling", "loggers": ["todoist"], diff --git a/homeassistant/components/todoist/strings.json b/homeassistant/components/todoist/strings.json index 1ed092e5cf6e11..123b5d07ed77d3 100644 --- a/homeassistant/components/todoist/strings.json +++ b/homeassistant/components/todoist/strings.json @@ -1,4 +1,23 @@ { + "config": { + "step": { + "user": { + "data": { + "token": "[%key:common::config_flow::data::api_token%]" + }, + "description": "Please entry your API token from your [Todoist Settings page]({settings_url})" + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]" + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + }, "services": { "new_task": { "name": "New task", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 1557df8f33b21a..98935086b88e49 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -473,6 +473,7 @@ "tibber", "tile", "tilt_ble", + "todoist", "tolo", "tomorrowio", "toon", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7cad78a49fcb19..779ee92e9fe37f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5808,7 +5808,7 @@ "todoist": { "name": "Todoist", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "tolo": { diff --git a/tests/components/todoist/conftest.py b/tests/components/todoist/conftest.py new file mode 100644 index 00000000000000..6543e5b678f42b --- /dev/null +++ b/tests/components/todoist/conftest.py @@ -0,0 +1,135 @@ +"""Common fixtures for the todoist tests.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest +from requests.exceptions import HTTPError +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.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.common import MockConfigEntry + +SUMMARY = "A task" +TOKEN = "some-token" + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.todoist.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="due") +def mock_due() -> Due: + """Mock a todoist Task Due date/time.""" + return Due( + is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" + ) + + +@pytest.fixture(name="task") +def mock_task(due: Due) -> Task: + """Mock a todoist Task instance.""" + return Task( + assignee_id="1", + assigner_id="1", + comment_count=0, + is_completed=False, + content=SUMMARY, + created_at="2021-10-01T00:00:00", + creator_id="1", + description="A task", + due=due, + id="1", + labels=["Label1"], + order=1, + parent_id=None, + priority=1, + project_id="12345", + section_id=None, + url="https://todoist.com", + sync_id=None, + ) + + +@pytest.fixture(name="api") +def mock_api(task) -> AsyncMock: + """Mock the api state.""" + api = AsyncMock() + api.get_projects.return_value = [ + Project( + id="12345", + color="blue", + comment_count=0, + is_favorite=False, + name="Name", + is_shared=False, + url="", + is_inbox_project=False, + is_team_inbox=False, + order=1, + parent_id=None, + view_style="list", + ) + ] + api.get_labels.return_value = [ + Label(id="1", name="Label1", color="1", order=1, is_favorite=False) + ] + api.get_collaborators.return_value = [ + Collaborator(email="user@gmail.com", id="1", name="user") + ] + api.get_tasks.return_value = [task] + return api + + +@pytest.fixture(name="todoist_api_status") +def mock_api_status() -> HTTPStatus | None: + """Fixture to inject an http status error.""" + return None + + +@pytest.fixture(autouse=True) +def mock_api_side_effect( + api: AsyncMock, todoist_api_status: HTTPStatus | None +) -> MockConfigEntry: + """Mock todoist configuration.""" + if todoist_api_status: + response = Response() + response.status_code = todoist_api_status + api.get_tasks.side_effect = HTTPError(response=response) + + +@pytest.fixture(name="todoist_config_entry") +def mock_todoist_config_entry() -> MockConfigEntry: + """Mock todoist configuration.""" + return MockConfigEntry(domain=DOMAIN, unique_id=TOKEN, data={CONF_TOKEN: TOKEN}) + + +@pytest.fixture(name="todoist_domain") +def mock_todoist_domain() -> str: + """Mock todoist configuration.""" + return DOMAIN + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, + 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): + assert await async_setup_component(hass, DOMAIN, {}) + yield diff --git a/tests/components/todoist/test_calendar.py b/tests/components/todoist/test_calendar.py index 921439fab45533..45300e2e66cc5b 100644 --- a/tests/components/todoist/test_calendar.py +++ b/tests/components/todoist/test_calendar.py @@ -7,7 +7,7 @@ import zoneinfo import pytest -from todoist_api_python.models import Collaborator, Due, Label, Project, Task +from todoist_api_python.models import Due from homeassistant import setup from homeassistant.components.todoist.const import ( @@ -24,9 +24,10 @@ from homeassistant.helpers.entity_component import async_update_entity from homeassistant.util import dt as dt_util +from .conftest import SUMMARY + from tests.typing import ClientSessionGenerator -SUMMARY = "A task" # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round TZ_NAME = "America/Regina" @@ -39,69 +40,6 @@ def set_time_zone(hass: HomeAssistant): hass.config.set_time_zone(TZ_NAME) -@pytest.fixture(name="due") -def mock_due() -> Due: - """Mock a todoist Task Due date/time.""" - return Due( - is_recurring=False, date=dt_util.now().strftime("%Y-%m-%d"), string="today" - ) - - -@pytest.fixture(name="task") -def mock_task(due: Due) -> Task: - """Mock a todoist Task instance.""" - return Task( - assignee_id="1", - assigner_id="1", - comment_count=0, - is_completed=False, - content=SUMMARY, - created_at="2021-10-01T00:00:00", - creator_id="1", - description="A task", - due=due, - id="1", - labels=["Label1"], - order=1, - parent_id=None, - priority=1, - project_id="12345", - section_id=None, - url="https://todoist.com", - sync_id=None, - ) - - -@pytest.fixture(name="api") -def mock_api(task) -> AsyncMock: - """Mock the api state.""" - api = AsyncMock() - api.get_projects.return_value = [ - Project( - id="12345", - color="blue", - comment_count=0, - is_favorite=False, - name="Name", - is_shared=False, - url="", - is_inbox_project=False, - is_team_inbox=False, - order=1, - parent_id=None, - view_style="list", - ) - ] - api.get_labels.return_value = [ - Label(id="1", name="Label1", color="1", order=1, is_favorite=False) - ] - api.get_collaborators.return_value = [ - Collaborator(email="user@gmail.com", id="1", name="user") - ] - api.get_tasks.return_value = [task] - return api - - def get_events_url(entity: str, start: str, end: str) -> str: """Create a url to get events during the specified time range.""" return f"/api/calendars/{entity}?start={urllib.parse.quote(start)}&end={urllib.parse.quote(end)}" @@ -127,8 +65,8 @@ def mock_todoist_config() -> dict[str, Any]: return {} -@pytest.fixture(name="setup_integration", autouse=True) -async def mock_setup_integration( +@pytest.fixture(name="setup_platform", autouse=True) +async def mock_setup_platform( hass: HomeAssistant, api: AsyncMock, todoist_config: dict[str, Any], @@ -215,7 +153,7 @@ async def test_update_entity_for_calendar_with_due_date_in_the_future( assert state.attributes["end_time"] == expected_end_time -@pytest.mark.parametrize("setup_integration", [None]) +@pytest.mark.parametrize("setup_platform", [None]) async def test_failed_coordinator_update(hass: HomeAssistant, api: AsyncMock) -> None: """Test a failed data coordinator update is handled correctly.""" api.get_tasks.side_effect = Exception("API error") @@ -417,3 +355,44 @@ async def test_task_due_datetime( ) assert response.status == HTTPStatus.OK assert await response.json() == [] + + +@pytest.mark.parametrize( + ("due", "setup_platform"), + [ + ( + Due( + date="2023-03-30", + is_recurring=False, + string="Mar 30 6:00 PM", + datetime="2023-03-31T00:00:00Z", + timezone="America/Regina", + ), + None, + ) + ], +) +async def test_config_entry( + hass: HomeAssistant, + setup_integration: None, + hass_client: ClientSessionGenerator, +) -> None: + """Test for a calendar created with a config entry.""" + + await async_update_entity(hass, "calendar.name") + state = hass.states.get("calendar.name") + assert state + + client = await hass_client() + response = await client.get( + get_events_url( + "calendar.name", "2023-03-30T08:00:00.000Z", "2023-03-31T08:00:00.000Z" + ), + ) + assert response.status == HTTPStatus.OK + assert await response.json() == [ + get_events_response( + {"dateTime": "2023-03-30T18:00:00-06:00"}, + {"dateTime": "2023-03-31T18:00:00-06:00"}, + ) + ] diff --git a/tests/components/todoist/test_config_flow.py b/tests/components/todoist/test_config_flow.py new file mode 100644 index 00000000000000..4175902da3131b --- /dev/null +++ b/tests/components/todoist/test_config_flow.py @@ -0,0 +1,123 @@ +"""Test the todoist config flow.""" + +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.const import CONF_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import TOKEN + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +@pytest.fixture(autouse=True) +async def patch_api( + api: AsyncMock, +) -> None: + """Mock setup of the todoist integration.""" + with patch( + "homeassistant.components.todoist.config_flow.TodoistAPIAsync", return_value=api + ): + yield + + +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.get("type") == FlowResultType.FORM + assert not result.get("errors") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + await hass.async_block_till_done() + + assert result2.get("type") == FlowResultType.CREATE_ENTRY + assert result2.get("title") == "Todoist" + assert result2.get("data") == { + CONF_TOKEN: TOKEN, + } + assert len(mock_setup_entry.mock_calls) == 1 + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +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} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "invalid_access_token"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +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} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "cannot_connect"} + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.UNAUTHORIZED]) +async def test_unknown_error(hass: HomeAssistant, api: AsyncMock) -> None: + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + api.get_tasks.side_effect = ValueError("unexpected") + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_TOKEN: TOKEN, + }, + ) + + assert result2.get("type") == FlowResultType.FORM + assert result2.get("errors") == {"base": "unknown"} + + +async def test_already_configured(hass: HomeAssistant, setup_integration: None) -> None: + """Test that only a single instance can be configured.""" + + 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} + ) + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "single_instance_allowed" diff --git a/tests/components/todoist/test_init.py b/tests/components/todoist/test_init.py new file mode 100644 index 00000000000000..cc64464df1d0d7 --- /dev/null +++ b/tests/components/todoist/test_init.py @@ -0,0 +1,47 @@ +"""Unit tests for the Todoist integration.""" +from collections.abc import Generator +from http import HTTPStatus +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.todoist.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +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, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test loading and unloading of the config entry.""" + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + + assert todoist_config_entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(todoist_config_entry.entry_id) + assert todoist_config_entry.state == ConfigEntryState.NOT_LOADED + + +@pytest.mark.parametrize("todoist_api_status", [HTTPStatus.INTERNAL_SERVER_ERROR]) +async def test_init_failure( + hass: HomeAssistant, + setup_integration: None, + api: AsyncMock, + todoist_config_entry: MockConfigEntry | None, +) -> None: + """Test an initialization error on integration load.""" + assert todoist_config_entry.state == ConfigEntryState.SETUP_RETRY From e8ed4c1ace2bf266e7e20f504244ee9c4d39008a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 01:56:02 -0500 Subject: [PATCH 367/640] Bump dbus-fast to 2.6.0 (#100163) changelog: https://github.com/Bluetooth-Devices/dbus-fast/compare/v2.4.0...v2.6.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 762117052f05f0..cd74d9b6c97efa 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.11.0", - "dbus-fast==2.4.0" + "dbus-fast==2.6.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index fa9fade40317df..5aaf114f1b8d92 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.4.0 +dbus-fast==2.6.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 hass-nabucasa==0.70.0 diff --git a/requirements_all.txt b/requirements_all.txt index eaeab3d151a13e..f4af1e1ab6e3b8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.4.0 +dbus-fast==2.6.0 # homeassistant.components.debugpy debugpy==1.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d81c6ecb22bcfa..4dac8f2f402e41 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.4.0 +dbus-fast==2.6.0 # homeassistant.components.debugpy debugpy==1.6.7 From 80b03b4acb149cce6b7d2195416a53ff2a9d0d2b Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 08:59:39 +0200 Subject: [PATCH 368/640] Adjust tasmota sensor device class and icon mapping (#100168) --- homeassistant/components/tasmota/sensor.py | 18 +++++++----------- 1 file changed, 7 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index ddcdb3e8c26e4f..8365fd97ca468e 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -88,12 +88,10 @@ hc.SENSOR_COLOR_GREEN: {ICON: "mdi:palette"}, hc.SENSOR_COLOR_RED: {ICON: "mdi:palette"}, hc.SENSOR_CURRENT: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_CURRENTNEUTRAL: { - ICON: "mdi:alpha-a-circle-outline", DEVICE_CLASS: SensorDeviceClass.CURRENT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -103,11 +101,14 @@ STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_DISTANCE: { - ICON: "mdi:leak", DEVICE_CLASS: SensorDeviceClass.DISTANCE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_ECO2: {ICON: "mdi:molecule-co2"}, + hc.SENSOR_ENERGY: { + DEVICE_CLASS: SensorDeviceClass.ENERGY, + STATE_CLASS: SensorStateClass.TOTAL, + }, hc.SENSOR_FREQUENCY: { DEVICE_CLASS: SensorDeviceClass.FREQUENCY, STATE_CLASS: SensorStateClass.MEASUREMENT, @@ -122,10 +123,7 @@ }, hc.SENSOR_STATUS_IP: {ICON: "mdi:ip-network"}, hc.SENSOR_STATUS_LINK_COUNT: {ICON: "mdi:counter"}, - hc.SENSOR_MOISTURE: { - DEVICE_CLASS: SensorDeviceClass.MOISTURE, - ICON: "mdi:cup-water", - }, + hc.SENSOR_MOISTURE: {DEVICE_CLASS: SensorDeviceClass.MOISTURE}, hc.SENSOR_STATUS_MQTT_COUNT: {ICON: "mdi:counter"}, hc.SENSOR_PB0_3: {ICON: "mdi:flask"}, hc.SENSOR_PB0_5: {ICON: "mdi:flask"}, @@ -146,7 +144,6 @@ STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_POWERFACTOR: { - ICON: "mdi:alpha-f-circle-outline", DEVICE_CLASS: SensorDeviceClass.POWER_FACTOR, STATE_CLASS: SensorStateClass.MEASUREMENT, }, @@ -162,7 +159,7 @@ DEVICE_CLASS: SensorDeviceClass.PRESSURE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, - hc.SENSOR_PROXIMITY: {DEVICE_CLASS: SensorDeviceClass.DISTANCE, ICON: "mdi:ruler"}, + hc.SENSOR_PROXIMITY: {ICON: "mdi:ruler"}, hc.SENSOR_REACTIVE_ENERGYEXPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_ENERGYIMPORT: {STATE_CLASS: SensorStateClass.TOTAL}, hc.SENSOR_REACTIVE_POWERUSAGE: { @@ -195,11 +192,10 @@ hc.SENSOR_TOTAL_START_TIME: {ICON: "mdi:progress-clock"}, hc.SENSOR_TVOC: {ICON: "mdi:air-filter"}, hc.SENSOR_VOLTAGE: { - ICON: "mdi:alpha-v-circle-outline", + DEVICE_CLASS: SensorDeviceClass.VOLTAGE, STATE_CLASS: SensorStateClass.MEASUREMENT, }, hc.SENSOR_WEIGHT: { - ICON: "mdi:scale", DEVICE_CLASS: SensorDeviceClass.WEIGHT, STATE_CLASS: SensorStateClass.MEASUREMENT, }, From da13afbd3c949298abd2c0c68fcb486ae06eccfd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 12 Sep 2023 16:08:06 +0900 Subject: [PATCH 369/640] Add missing AEMET wind gust speed (#100157) --- homeassistant/components/aemet/sensor.py | 10 +++++++++- homeassistant/components/aemet/weather.py | 6 ++++++ tests/components/aemet/test_sensor.py | 3 +++ tests/components/aemet/test_weather.py | 3 +++ 4 files changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/aemet/sensor.py b/homeassistant/components/aemet/sensor.py index f7aa6b358933f8..76e691a4682221 100644 --- a/homeassistant/components/aemet/sensor.py +++ b/homeassistant/components/aemet/sensor.py @@ -30,6 +30,7 @@ ATTR_API_FORECAST_TEMP_LOW, ATTR_API_FORECAST_TIME, ATTR_API_FORECAST_WIND_BEARING, + ATTR_API_FORECAST_WIND_MAX_SPEED, ATTR_API_FORECAST_WIND_SPEED, ATTR_API_HUMIDITY, ATTR_API_PRESSURE, @@ -99,6 +100,12 @@ name="Wind bearing", native_unit_of_measurement=DEGREE, ), + SensorEntityDescription( + key=ATTR_API_FORECAST_WIND_MAX_SPEED, + name="Wind max speed", + native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, + device_class=SensorDeviceClass.WIND_SPEED, + ), SensorEntityDescription( key=ATTR_API_FORECAST_WIND_SPEED, name="Wind speed", @@ -206,13 +213,14 @@ name="Wind max speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key=ATTR_API_WIND_SPEED, name="Wind speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, - state_class=SensorStateClass.MEASUREMENT, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), ) diff --git a/homeassistant/components/aemet/weather.py b/homeassistant/components/aemet/weather.py index e3a1922c2f1d48..03f91a74740f8a 100644 --- a/homeassistant/components/aemet/weather.py +++ b/homeassistant/components/aemet/weather.py @@ -42,6 +42,7 @@ ATTR_API_PRESSURE, ATTR_API_TEMPERATURE, ATTR_API_WIND_BEARING, + ATTR_API_WIND_MAX_SPEED, ATTR_API_WIND_SPEED, ATTRIBUTION, DOMAIN, @@ -193,6 +194,11 @@ def wind_bearing(self): """Return the wind bearing.""" return self.coordinator.data[ATTR_API_WIND_BEARING] + @property + def native_wind_gust_speed(self): + """Return the wind gust speed in native units.""" + return self.coordinator.data[ATTR_API_WIND_MAX_SPEED] + @property def native_wind_speed(self): """Return the wind speed.""" diff --git a/tests/components/aemet/test_sensor.py b/tests/components/aemet/test_sensor.py index 8237987bf44705..7b6f02f8b06fa6 100644 --- a/tests/components/aemet/test_sensor.py +++ b/tests/components/aemet/test_sensor.py @@ -66,6 +66,9 @@ async def test_aemet_forecast_create_sensors( state = hass.states.get("sensor.aemet_hourly_forecast_wind_bearing") assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_max_speed") + assert state is None + state = hass.states.get("sensor.aemet_hourly_forecast_wind_speed") assert state is None diff --git a/tests/components/aemet/test_weather.py b/tests/components/aemet/test_weather.py index ddcc29698fdf8e..d0042faaaa0640 100644 --- a/tests/components/aemet/test_weather.py +++ b/tests/components/aemet/test_weather.py @@ -26,6 +26,7 @@ ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, ATTR_WEATHER_WIND_BEARING, + ATTR_WEATHER_WIND_GUST_SPEED, ATTR_WEATHER_WIND_SPEED, DOMAIN as WEATHER_DOMAIN, SERVICE_GET_FORECAST, @@ -58,6 +59,7 @@ async def test_aemet_weather( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY @@ -101,6 +103,7 @@ async def test_aemet_weather_legacy( assert state.attributes.get(ATTR_WEATHER_PRESSURE) == 1004.4 # 100440.0 Pa -> hPa assert state.attributes.get(ATTR_WEATHER_TEMPERATURE) == -0.7 assert state.attributes.get(ATTR_WEATHER_WIND_BEARING) == 90.0 + assert state.attributes.get(ATTR_WEATHER_WIND_GUST_SPEED) == 24.0 assert state.attributes.get(ATTR_WEATHER_WIND_SPEED) == 15.0 # 4.17 m/s -> km/h forecast = state.attributes.get(ATTR_FORECAST)[0] assert forecast.get(ATTR_FORECAST_CONDITION) == ATTR_CONDITION_PARTLYCLOUDY From 5ba573a1b48ad51ed0daab40968526069bb393b0 Mon Sep 17 00:00:00 2001 From: Alex Yao <33379584+alexyao2015@users.noreply.github.com> Date: Tue, 12 Sep 2023 03:34:11 -0400 Subject: [PATCH 370/640] Add Life360 Location Update Button (#99559) Co-authored-by: Robert Resch Co-authored-by: alexyao2015 --- .coveragerc | 1 + homeassistant/components/life360/__init__.py | 2 +- homeassistant/components/life360/button.py | 56 +++++++++++++++++++ .../components/life360/coordinator.py | 6 ++ homeassistant/components/life360/strings.json | 7 +++ 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/life360/button.py diff --git a/.coveragerc b/.coveragerc index 4df91b250ed70d..686e3eaaadde62 100644 --- a/.coveragerc +++ b/.coveragerc @@ -654,6 +654,7 @@ omit = homeassistant/components/lg_soundbar/__init__.py homeassistant/components/lg_soundbar/media_player.py homeassistant/components/life360/__init__.py + homeassistant/components/life360/button.py homeassistant/components/life360/coordinator.py homeassistant/components/life360/device_tracker.py homeassistant/components/lightwave/* diff --git a/homeassistant/components/life360/__init__.py b/homeassistant/components/life360/__init__.py index 271f934e1c7d52..c6e0fad14c64ef 100644 --- a/homeassistant/components/life360/__init__.py +++ b/homeassistant/components/life360/__init__.py @@ -39,7 +39,7 @@ ) from .coordinator import Life360DataUpdateCoordinator, MissingLocReason -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.BUTTON] CONF_ACCOUNTS = "accounts" diff --git a/homeassistant/components/life360/button.py b/homeassistant/components/life360/button.py new file mode 100644 index 00000000000000..6b460c8531c403 --- /dev/null +++ b/homeassistant/components/life360/button.py @@ -0,0 +1,56 @@ +"""Support for Life360 buttons.""" +from homeassistant.components.button import ButtonEntity +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 homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import Life360DataUpdateCoordinator +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up Life360 buttons.""" + coordinator: Life360DataUpdateCoordinator = hass.data[DOMAIN].coordinators[ + config_entry.entry_id + ] + for member_id, member in coordinator.data.members.items(): + async_add_entities( + [ + Life360UpdateLocationButton(coordinator, member.circle_id, member_id), + ] + ) + + +class Life360UpdateLocationButton( + CoordinatorEntity[Life360DataUpdateCoordinator], ButtonEntity +): + """Represent an Life360 Update Location button.""" + + _attr_has_entity_name = True + _attr_translation_key = "update_location" + + def __init__( + self, + coordinator: Life360DataUpdateCoordinator, + circle_id: str, + member_id: str, + ) -> None: + """Initialize a new Life360 Update Location button.""" + super().__init__(coordinator) + self._circle_id = circle_id + self._member_id = member_id + self._attr_unique_id = f"{member_id}-update-location" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, member_id)}, + name=coordinator.data.members[member_id].name, + ) + + async def async_press(self) -> None: + """Handle the button press.""" + await self.coordinator.update_location(self._circle_id, self._member_id) diff --git a/homeassistant/components/life360/coordinator.py b/homeassistant/components/life360/coordinator.py index 5ea64d3f81d284..755fa1b812434d 100644 --- a/homeassistant/components/life360/coordinator.py +++ b/homeassistant/components/life360/coordinator.py @@ -65,6 +65,7 @@ class Life360Member: at_loc_since: datetime battery_charging: bool battery_level: int + circle_id: str driving: bool entity_picture: str gps_accuracy: int @@ -118,6 +119,10 @@ async def _retrieve_data(self, func: str, *args: Any) -> list[dict[str, Any]]: LOGGER.debug("%s: %s", exc.__class__.__name__, exc) raise UpdateFailed(exc) from exc + async def update_location(self, circle_id: str, member_id: str) -> None: + """Update location for given Circle and Member.""" + await self._retrieve_data("update_location", circle_id, member_id) + async def _async_update_data(self) -> Life360Data: """Get & process data from Life360.""" @@ -214,6 +219,7 @@ async def _async_update_data(self) -> Life360Data: dt_util.utc_from_timestamp(int(loc["since"])), bool(int(loc["charge"])), int(float(loc["battery"])), + circle_id, bool(int(loc["isDriving"])), member["avatar"], # Life360 reports accuracy in feet, but Device Tracker expects diff --git a/homeassistant/components/life360/strings.json b/homeassistant/components/life360/strings.json index cc31ca64a0870a..343d9e95bb84b0 100644 --- a/homeassistant/components/life360/strings.json +++ b/homeassistant/components/life360/strings.json @@ -27,6 +27,13 @@ "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, + "entity": { + "button": { + "update_location": { + "name": "Update Location" + } + } + }, "options": { "step": { "init": { From 27c430bbac6e57ee84e420a7986cc54ee62476b8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 09:36:07 +0200 Subject: [PATCH 371/640] Use shorthand attributes in Smart meter texas (#99838) Co-authored-by: Robert Resch --- .../components/smart_meter_texas/sensor.py | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index 7552f2c0697d29..d237daf01caccc 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -47,50 +47,30 @@ class SmartMeterTexasSensor(CoordinatorEntity, RestoreEntity, SensorEntity): _attr_device_class = SensorDeviceClass.ENERGY _attr_state_class = SensorStateClass.TOTAL_INCREASING _attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR + _attr_available = False def __init__(self, meter: Meter, coordinator: DataUpdateCoordinator) -> None: """Initialize the sensor.""" super().__init__(coordinator) self.meter = meter - self._state = None - self._available = False - - @property - def name(self): - """Device Name.""" - return f"{ELECTRIC_METER} {self.meter.meter}" - - @property - def unique_id(self): - """Device Uniqueid.""" - return f"{self.meter.esiid}_{self.meter.meter}" - - @property - def available(self): - """Return True if entity is available.""" - return self._available - - @property - def native_value(self): - """Get the latest reading.""" - return self._state + self._attr_name = f"{ELECTRIC_METER} {meter.meter}" + self._attr_unique_id = f"{meter.esiid}_{meter.meter}" @property def extra_state_attributes(self): """Return the device specific state attributes.""" - attributes = { + return { METER_NUMBER: self.meter.meter, ESIID: self.meter.esiid, CONF_ADDRESS: self.meter.address, } - return attributes @callback def _state_update(self): """Call when the coordinator has an update.""" - self._available = self.coordinator.last_update_success - if self._available: - self._state = self.meter.reading + self._attr_available = self.coordinator.last_update_success + if self._attr_available: + self._attr_native_value = self.meter.reading self.async_write_ha_state() async def async_added_to_hass(self): @@ -104,5 +84,5 @@ async def async_added_to_hass(self): return if last_state := await self.async_get_last_state(): - self._state = last_state.state - self._available = True + self._attr_native_value = last_state.state + self._attr_available = True From 5bcb4f07a00f27cf1ddd825a11cbf8985e0e2d11 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 09:58:05 +0200 Subject: [PATCH 372/640] Bump hatasmota to 0.7.3 (#100169) --- .../components/tasmota/manifest.json | 2 +- homeassistant/components/tasmota/sensor.py | 1 - requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/tasmota/test_sensor.py | 214 ++++++++++++++++++ 5 files changed, 217 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tasmota/manifest.json b/homeassistant/components/tasmota/manifest.json index fa34665cd737eb..42fc849a2cf10d 100644 --- a/homeassistant/components/tasmota/manifest.json +++ b/homeassistant/components/tasmota/manifest.json @@ -8,5 +8,5 @@ "iot_class": "local_push", "loggers": ["hatasmota"], "mqtt": ["tasmota/discovery/#"], - "requirements": ["HATasmota==0.7.2"] + "requirements": ["HATasmota==0.7.3"] } diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index 8365fd97ca468e..e718c0fdcf4f37 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -216,7 +216,6 @@ hc.LIGHT_LUX: LIGHT_LUX, hc.MASS_KILOGRAMS: UnitOfMass.KILOGRAMS, hc.PERCENTAGE: PERCENTAGE, - hc.POWER_FACTOR: None, hc.POWER_WATT: UnitOfPower.WATT, hc.PRESSURE_HPA: UnitOfPressure.HPA, hc.REACTIVE_POWER: POWER_VOLT_AMPERE_REACTIVE, diff --git a/requirements_all.txt b/requirements_all.txt index f4af1e1ab6e3b8..4295701da220eb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -29,7 +29,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.2 +HATasmota==0.7.3 # homeassistant.components.mastodon Mastodon.py==1.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4dac8f2f402e41..22e385da3d51d6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -28,7 +28,7 @@ DoorBirdPy==2.1.0 HAP-python==4.7.1 # homeassistant.components.tasmota -HATasmota==0.7.2 +HATasmota==0.7.3 # homeassistant.components.doods # homeassistant.components.generic diff --git a/tests/components/tasmota/test_sensor.py b/tests/components/tasmota/test_sensor.py index c14c7ffe53c4ae..2f50a84ffdd1a1 100644 --- a/tests/components/tasmota/test_sensor.py +++ b/tests/components/tasmota/test_sensor.py @@ -137,6 +137,27 @@ } } +NUMBERED_SENSOR_CONFIG = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "Temperature1": 2.4, + "Temperature2": 2.4, + "Illuminance3": 2.4, + }, + "TempUnit": "C", + } +} + +NUMBERED_SENSOR_CONFIG_2 = { + "sn": { + "Time": "2020-09-25T12:47:15", + "ANALOG": { + "CTEnergy1": {"Energy": 0.5, "Power": 2300, "Voltage": 230, "Current": 10}, + }, + "TempUnit": "C", + } +} TEMPERATURE_SENSOR_CONFIG = { "sn": { @@ -343,6 +364,118 @@ }, ), ), + ( + NUMBERED_SENSOR_CONFIG, + [ + "sensor.tasmota_analog_temperature1", + "sensor.tasmota_analog_temperature2", + "sensor.tasmota_analog_illuminance3", + ], + ( + ( + '{"ANALOG":{"Temperature1":1.2,"Temperature2":3.4,' + '"Illuminance3": 5.6}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"Temperature1": 7.8,"Temperature2": 9.0,' + '"Illuminance3":1.2}}}' + ), + ), + ( + { + "sensor.tasmota_analog_temperature1": { + "state": "1.2", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_temperature2": { + "state": "3.4", + "attributes": { + "device_class": "temperature", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "°C", + }, + }, + "sensor.tasmota_analog_illuminance3": { + "state": "5.6", + "attributes": { + "device_class": "illuminance", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "lx", + }, + }, + }, + { + "sensor.tasmota_analog_temperature1": {"state": "7.8"}, + "sensor.tasmota_analog_temperature2": {"state": "9.0"}, + "sensor.tasmota_analog_illuminance3": {"state": "1.2"}, + }, + ), + ), + ( + NUMBERED_SENSOR_CONFIG_2, + [ + "sensor.tasmota_analog_ctenergy1_energy", + "sensor.tasmota_analog_ctenergy1_power", + "sensor.tasmota_analog_ctenergy1_voltage", + "sensor.tasmota_analog_ctenergy1_current", + ], + ( + ( + '{"ANALOG":{"CTEnergy1":' + '{"Energy":0.5,"Power":2300,"Voltage":230,"Current":10}}}' + ), + ( + '{"StatusSNS":{"ANALOG":{"CTEnergy1":' + '{"Energy":1.0,"Power":1150,"Voltage":230,"Current":5}}}}' + ), + ), + ( + { + "sensor.tasmota_analog_ctenergy1_energy": { + "state": "0.5", + "attributes": { + "device_class": "energy", + ATTR_STATE_CLASS: SensorStateClass.TOTAL, + "unit_of_measurement": "kWh", + }, + }, + "sensor.tasmota_analog_ctenergy1_power": { + "state": "2300", + "attributes": { + "device_class": "power", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "W", + }, + }, + "sensor.tasmota_analog_ctenergy1_voltage": { + "state": "230", + "attributes": { + "device_class": "voltage", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "V", + }, + }, + "sensor.tasmota_analog_ctenergy1_current": { + "state": "10", + "attributes": { + "device_class": "current", + ATTR_STATE_CLASS: SensorStateClass.MEASUREMENT, + "unit_of_measurement": "A", + }, + }, + }, + { + "sensor.tasmota_analog_ctenergy1_energy": {"state": "1.0"}, + "sensor.tasmota_analog_ctenergy1_power": {"state": "1150"}, + "sensor.tasmota_analog_ctenergy1_voltage": {"state": "230"}, + "sensor.tasmota_analog_ctenergy1_current": {"state": "5"}, + }, + ), + ), ], ) async def test_controlling_state_via_mqtt( @@ -409,6 +542,87 @@ async def test_controlling_state_via_mqtt( assert state.attributes.get(attribute) == expected +@pytest.mark.parametrize( + ("sensor_config", "entity_ids", "states"), + [ + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "AS3935": {"Energy": None}}}, + ["sensor.tasmota_as3935_energy"], + { + "sensor.tasmota_as3935_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # The AS33935 energy sensor is not reporting energy in W + {"sn": {"Time": "2020-09-25T12:47:15", "LD2410": {"Energy": None}}}, + ["sensor.tasmota_ld2410_energy"], + { + "sensor.tasmota_ld2410_energy": { + "device_class": None, + "state_class": None, + "unit_of_measurement": None, + }, + }, + ), + ( + # Check other energy sensors work + {"sn": {"Time": "2020-09-25T12:47:15", "Other": {"Energy": None}}}, + ["sensor.tasmota_other_energy"], + { + "sensor.tasmota_other_energy": { + "device_class": "energy", + "state_class": "total", + "unit_of_measurement": "kWh", + }, + }, + ), + ], +) +async def test_quantity_override( + hass: HomeAssistant, + mqtt_mock: MqttMockHAClient, + setup_tasmota, + sensor_config, + entity_ids, + states, +) -> None: + """Test quantity override for certain sensors.""" + entity_reg = er.async_get(hass) + config = copy.deepcopy(DEFAULT_CONFIG) + sensor_config = copy.deepcopy(sensor_config) + mac = config["mac"] + + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/config", + json.dumps(config), + ) + await hass.async_block_till_done() + async_fire_mqtt_message( + hass, + f"{DEFAULT_PREFIX}/{mac}/sensors", + json.dumps(sensor_config), + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == "unavailable" + expected_state = states[entity_id] + for attribute, expected in expected_state.get("attributes", {}).items(): + assert state.attributes.get(attribute) == expected + + entry = entity_reg.async_get(entity_id) + assert entry.disabled is False + assert entry.disabled_by is None + assert entry.entity_category is None + + async def test_bad_indexed_sensor_state_via_mqtt( hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota ) -> None: From 0cd73e397bb243183e17627988eabd0363c9561f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:43:13 +0200 Subject: [PATCH 373/640] Bump tibdex/github-app-token from 1.8.2 to 2.0.0 (#100099) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index a0a86d0e868dda..212cd0498b6bb2 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@0d49dd721133f900ebd5e0dff2810704e8defbc6 + uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From fead9d3a9233f7b0d8a77369290d88cfc3f00739 Mon Sep 17 00:00:00 2001 From: Vincent Knoop Pathuis <48653141+vpathuis@users.noreply.github.com> Date: Tue, 12 Sep 2023 10:45:35 +0200 Subject: [PATCH 374/640] Bump Ultraheat to version 0.5.7 (#100172) --- homeassistant/components/landisgyr_heat_meter/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/landisgyr_heat_meter/manifest.json b/homeassistant/components/landisgyr_heat_meter/manifest.json index a056f1f65645fa..1bf77d7ab5166c 100644 --- a/homeassistant/components/landisgyr_heat_meter/manifest.json +++ b/homeassistant/components/landisgyr_heat_meter/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["usb"], "documentation": "https://www.home-assistant.io/integrations/landisgyr_heat_meter", "iot_class": "local_polling", - "requirements": ["ultraheat-api==0.5.1"] + "requirements": ["ultraheat-api==0.5.7"] } diff --git a/requirements_all.txt b/requirements_all.txt index 4295701da220eb..ce2842b6316de4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2607,7 +2607,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 22e385da3d51d6..14831d4fa592a6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1913,7 +1913,7 @@ twitchAPI==3.10.0 uasiren==0.0.1 # homeassistant.components.landisgyr_heat_meter -ultraheat-api==0.5.1 +ultraheat-api==0.5.7 # homeassistant.components.unifiprotect unifi-discovery==1.1.7 From 71207e112ece14161ee65bdf8b9ff937697b0b16 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 10:59:50 +0200 Subject: [PATCH 375/640] Bring modbus naming in sync with standard (#99285) --- homeassistant/components/modbus/modbus.py | 28 +++++++++++++---------- tests/components/modbus/test_init.py | 10 ++++---- 2 files changed, 21 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 238df4466c432c..a503b71593c5aa 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -168,11 +168,12 @@ async def async_stop_modbus(event: Event) -> None: async def async_write_register(service: ServiceCall) -> None: """Write Modbus registers.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) + if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = int(float(service.data[ATTR_ADDRESS])) value = service.data[ATTR_VALUE] hub = hub_collect[ @@ -180,29 +181,32 @@ async def async_write_register(service: ServiceCall) -> None: ] if isinstance(value, list): await hub.async_pb_call( - unit, address, [int(float(i)) for i in value], CALL_TYPE_WRITE_REGISTERS + slave, + address, + [int(float(i)) for i in value], + CALL_TYPE_WRITE_REGISTERS, ) else: await hub.async_pb_call( - unit, address, int(float(value)), CALL_TYPE_WRITE_REGISTER + slave, address, int(float(value)), CALL_TYPE_WRITE_REGISTER ) async def async_write_coil(service: ServiceCall) -> None: """Write Modbus coil.""" - unit = 0 + slave = 0 if ATTR_UNIT in service.data: - unit = int(float(service.data[ATTR_UNIT])) + slave = int(float(service.data[ATTR_UNIT])) if ATTR_SLAVE in service.data: - unit = int(float(service.data[ATTR_SLAVE])) + slave = int(float(service.data[ATTR_SLAVE])) address = service.data[ATTR_ADDRESS] state = service.data[ATTR_STATE] hub = hub_collect[ service.data[ATTR_HUB] if ATTR_HUB in service.data else DEFAULT_HUB ] if isinstance(state, list): - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COILS) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COILS) else: - await hub.async_pb_call(unit, address, state, CALL_TYPE_WRITE_COIL) + await hub.async_pb_call(slave, address, state, CALL_TYPE_WRITE_COIL) for x_write in ( (SERVICE_WRITE_REGISTER, async_write_register, ATTR_VALUE, cv.positive_int), @@ -405,10 +409,10 @@ def pb_connect(self) -> bool: return True def pb_call( - self, unit: int | None, address: int, value: int | list[int], use_call: str + self, slave: int | None, address: int, value: int | list[int], use_call: str ) -> ModbusResponse | None: """Call sync. pymodbus.""" - kwargs = {"slave": unit} if unit else {} + kwargs = {"slave": slave} if slave else {} entry = self._pb_request[use_call] try: result: ModbusResponse = entry.func(address, value, **kwargs) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 6f88a4b7399c68..5d419ed28d587e 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -566,17 +566,17 @@ async def test_config_modbus( ], ) @pytest.mark.parametrize( - "do_unit", + "do_slave", [ - ATTR_UNIT, ATTR_SLAVE, + ATTR_UNIT, ], ) async def test_pb_service_write( hass: HomeAssistant, do_write, do_return, - do_unit, + do_slave, caplog: pytest.LogCaptureFixture, mock_modbus_with_pymodbus, ) -> None: @@ -591,7 +591,7 @@ async def test_pb_service_write( data = { ATTR_HUB: TEST_MODBUS_NAME, - do_unit: 17, + do_slave: 17, ATTR_ADDRESS: 16, do_write[DATA]: do_write[VALUE], } @@ -932,7 +932,7 @@ async def test_write_no_client(hass: HomeAssistant, mock_modbus) -> None: data = { ATTR_HUB: TEST_MODBUS_NAME, - ATTR_UNIT: 17, + ATTR_SLAVE: 17, ATTR_ADDRESS: 16, ATTR_STATE: True, } From 6b628f2d2904360e7ba50c965148fdde451bc618 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 14:02:50 +0200 Subject: [PATCH 376/640] Remove unnecessary block use of pylint disable in components a-o (#100190) --- homeassistant/components/cast/helpers.py | 2 +- homeassistant/components/fritz/common.py | 2 +- homeassistant/components/geniushub/__init__.py | 7 +++---- homeassistant/components/hdmi_cec/__init__.py | 5 +++-- homeassistant/components/limitlessled/light.py | 6 ++---- homeassistant/components/nx584/binary_sensor.py | 2 +- homeassistant/components/opentherm_gw/__init__.py | 3 +-- 7 files changed, 12 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/cast/helpers.py b/homeassistant/components/cast/helpers.py index c6a92c21fb4627..8b8862ab318bf4 100644 --- a/homeassistant/components/cast/helpers.py +++ b/homeassistant/components/cast/helpers.py @@ -214,7 +214,7 @@ def invalidate(self): All following callbacks won't be forwarded. """ - # pylint: disable=protected-access + # pylint: disable-next=protected-access if self._cast_device._cast_info.is_audio_group: self._mz_mgr.remove_multizone(self._uuid) else: diff --git a/homeassistant/components/fritz/common.py b/homeassistant/components/fritz/common.py index 76368175ca0ded..2abba137fbf359 100644 --- a/homeassistant/components/fritz/common.py +++ b/homeassistant/components/fritz/common.py @@ -566,7 +566,7 @@ async def async_scan_devices(self, now: datetime | None = None) -> None: self.fritz_hosts.get_mesh_topology ) ): - # pylint: disable=broad-exception-raised + # pylint: disable-next=broad-exception-raised raise Exception("Mesh supported but empty topology reported") except FritzActionError: self.mesh_role = MeshRoles.SLAVE diff --git a/homeassistant/components/geniushub/__init__.py b/homeassistant/components/geniushub/__init__.py index bed622eebf6041..955c76fe0fc8e2 100644 --- a/homeassistant/components/geniushub/__init__.py +++ b/homeassistant/components/geniushub/__init__.py @@ -212,11 +212,10 @@ async def async_update(self, now, **kwargs) -> None: def make_debug_log_entries(self) -> None: """Make any useful debug log entries.""" - # pylint: disable=protected-access _LOGGER.debug( "Raw JSON: \n\nclient._zones = %s \n\nclient._devices = %s", - self.client._zones, - self.client._devices, + self.client._zones, # pylint: disable=protected-access + self.client._devices, # pylint: disable=protected-access ) @@ -309,7 +308,7 @@ async def _refresh(self, payload: dict | None = None) -> None: mode = payload["data"][ATTR_ZONE_MODE] - # pylint: disable=protected-access + # pylint: disable-next=protected-access if mode == "footprint" and not self._zone._has_pir: raise TypeError( f"'{self.entity_id}' cannot support footprint mode (it has no PIR)" diff --git a/homeassistant/components/hdmi_cec/__init__.py b/homeassistant/components/hdmi_cec/__init__.py index 459f03edfbbec5..19621e28d032a5 100644 --- a/homeassistant/components/hdmi_cec/__init__.py +++ b/homeassistant/components/hdmi_cec/__init__.py @@ -123,11 +123,12 @@ SERVICE_POWER_ON = "power_on" SERVICE_STANDBY = "standby" -# pylint: disable=unnecessary-lambda DEVICE_SCHEMA: vol.Schema = vol.Schema( { vol.All(cv.positive_int): vol.Any( - lambda devices: DEVICE_SCHEMA(devices), cv.string + # pylint: disable-next=unnecessary-lambda + lambda devices: DEVICE_SCHEMA(devices), + cv.string, ) } ) diff --git a/homeassistant/components/limitlessled/light.py b/homeassistant/components/limitlessled/light.py index 6677768dd0045f..c1dfeda172c1bf 100644 --- a/homeassistant/components/limitlessled/light.py +++ b/homeassistant/components/limitlessled/light.py @@ -182,20 +182,18 @@ def decorator(function): def wrapper(self: LimitlessLEDGroup, **kwargs: Any) -> None: """Wrap a group state change.""" - # pylint: disable=protected-access - pipeline = Pipeline() transition_time = DEFAULT_TRANSITION if self.effect == EFFECT_COLORLOOP: self.group.stop() - self._attr_effect = None + self._attr_effect = None # pylint: disable=protected-access # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) # Do group type-specific work. function(self, transition_time, pipeline, **kwargs) # Update state. - self._attr_is_on = new_state + self._attr_is_on = new_state # pylint: disable=protected-access self.group.enqueue(pipeline) self.schedule_update_ha_state() diff --git a/homeassistant/components/nx584/binary_sensor.py b/homeassistant/components/nx584/binary_sensor.py index 853f5686831732..ca55ea25c40e03 100644 --- a/homeassistant/components/nx584/binary_sensor.py +++ b/homeassistant/components/nx584/binary_sensor.py @@ -131,9 +131,9 @@ def __init__(self, client, zone_sensors): def _process_zone_event(self, event): zone = event["zone"] - # pylint: disable=protected-access if not (zone_sensor := self._zone_sensors.get(zone)): return + # pylint: disable-next=protected-access zone_sensor._zone["state"] = event["zone_state"] zone_sensor.schedule_update_ha_state() diff --git a/homeassistant/components/opentherm_gw/__init__.py b/homeassistant/components/opentherm_gw/__init__.py index 0b8d4693cb817d..cd8b98880d55dc 100644 --- a/homeassistant/components/opentherm_gw/__init__.py +++ b/homeassistant/components/opentherm_gw/__init__.py @@ -163,8 +163,7 @@ def register_services(hass: HomeAssistant) -> None: vol.Required(ATTR_GW_ID): vol.All( cv.string, vol.In(hass.data[DATA_OPENTHERM_GW][DATA_GATEWAYS]) ), - # pylint: disable=unnecessary-lambda - vol.Optional(ATTR_DATE, default=lambda: date.today()): cv.date, + vol.Optional(ATTR_DATE, default=date.today): cv.date, vol.Optional(ATTR_TIME, default=lambda: datetime.now().time()): cv.time, } ) From 26ada307208379cc3933d522ff886b80e999cde9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 14:12:45 +0200 Subject: [PATCH 377/640] Remove default from deprecated close_comm_on_error (#100188) --- homeassistant/components/modbus/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index b4258d47d5e26e..a3c8928caafb50 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -336,7 +336,7 @@ { vol.Optional(CONF_NAME, default=DEFAULT_HUB): cv.string, vol.Optional(CONF_TIMEOUT, default=3): cv.socket_timeout, - vol.Optional(CONF_CLOSE_COMM_ON_ERROR, default=True): cv.boolean, + vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, From 1ca505c228fcb66c1b0704762cb79453156cdc44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 14:58:03 +0200 Subject: [PATCH 378/640] Use shorthand attributes in Wiffi (#99919) --- homeassistant/components/wiffi/__init__.py | 31 ++++----------- .../components/wiffi/binary_sensor.py | 10 ++--- homeassistant/components/wiffi/sensor.py | 38 ++++++++----------- 3 files changed, 27 insertions(+), 52 deletions(-) diff --git a/homeassistant/components/wiffi/__init__.py b/homeassistant/components/wiffi/__init__.py index 11ef186ba1508f..3a35ec1ed29421 100644 --- a/homeassistant/components/wiffi/__init__.py +++ b/homeassistant/components/wiffi/__init__.py @@ -144,7 +144,8 @@ class WiffiEntity(Entity): def __init__(self, device, metric, options): """Initialize the base elements of a wiffi entity.""" self._id = generate_unique_id(device, metric) - self._device_info = DeviceInfo( + self._attr_unique_id = self._id + self._attr_device_info = DeviceInfo( connections={(dr.CONNECTION_NETWORK_MAC, device.mac_address)}, identifiers={(DOMAIN, device.mac_address)}, manufacturer="stall.biz", @@ -153,7 +154,7 @@ def __init__(self, device, metric, options): sw_version=device.sw_version, configuration_url=device.configuration_url, ) - self._name = metric.description + self._attr_name = metric.description self._expiration_date = None self._value = None self._timeout = options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT) @@ -173,26 +174,6 @@ async def async_added_to_hass(self): ) ) - @property - def device_info(self): - """Return wiffi device info which is shared between all entities of a device.""" - return self._device_info - - @property - def unique_id(self): - """Return unique id for entity.""" - return self._id - - @property - def name(self): - """Return entity name.""" - return self._name - - @property - def available(self): - """Return true if value is valid.""" - return self._value is not None - def reset_expiration_date(self): """Reset value expiration date. @@ -221,8 +202,10 @@ def _check_expiration_date(self): def _is_measurement_entity(self): """Measurement entities have a value in present time.""" - return not self._name.endswith("_gestern") and not self._is_metered_entity() + return ( + not self._attr_name.endswith("_gestern") and not self._is_metered_entity() + ) def _is_metered_entity(self): """Metered entities have a value that keeps increasing until reset.""" - return self._name.endswith("_pro_h") or self._name.endswith("_heute") + return self._attr_name.endswith("_pro_h") or self._attr_name.endswith("_heute") diff --git a/homeassistant/components/wiffi/binary_sensor.py b/homeassistant/components/wiffi/binary_sensor.py index d0647b2529701e..cb1e1da41d8485 100644 --- a/homeassistant/components/wiffi/binary_sensor.py +++ b/homeassistant/components/wiffi/binary_sensor.py @@ -39,13 +39,13 @@ class BoolEntity(WiffiEntity, BinarySensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_is_on = metric.value self.reset_expiration_date() @property - def is_on(self): - """Return the state of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_is_on is not None @callback def _update_value_callback(self, device, metric): @@ -54,5 +54,5 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_is_on = metric.value self.async_write_ha_state() diff --git a/homeassistant/components/wiffi/sensor.py b/homeassistant/components/wiffi/sensor.py index 1036ac7986f9f4..e460a346bd7168 100644 --- a/homeassistant/components/wiffi/sensor.py +++ b/homeassistant/components/wiffi/sensor.py @@ -69,11 +69,13 @@ class NumberEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._device_class = UOM_TO_DEVICE_CLASS_MAP.get(metric.unit_of_measurement) - self._unit_of_measurement = UOM_MAP.get( + self._attr_device_class = UOM_TO_DEVICE_CLASS_MAP.get( + metric.unit_of_measurement + ) + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value if self._is_measurement_entity(): self._attr_state_class = SensorStateClass.MEASUREMENT @@ -83,19 +85,9 @@ def __init__(self, device, metric, options): self.reset_expiration_date() @property - def device_class(self): - """Return the automatically determined device class.""" - return self._device_class - - @property - def native_unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement - - @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -104,11 +96,11 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._unit_of_measurement = UOM_MAP.get( + self._attr_native_unit_of_measurement = UOM_MAP.get( metric.unit_of_measurement, metric.unit_of_measurement ) - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() @@ -119,13 +111,13 @@ class StringEntity(WiffiEntity, SensorEntity): def __init__(self, device, metric, options): """Initialize the entity.""" super().__init__(device, metric, options) - self._value = metric.value + self._attr_native_value = metric.value self.reset_expiration_date() @property - def native_value(self): - """Return the value of the entity.""" - return self._value + def available(self): + """Return true if value is valid.""" + return self._attr_native_value is not None @callback def _update_value_callback(self, device, metric): @@ -134,5 +126,5 @@ def _update_value_callback(self, device, metric): Called if a new message has been received from the wiffi device. """ self.reset_expiration_date() - self._value = metric.value + self._attr_native_value = metric.value self.async_write_ha_state() From 1cf2f2f8b82564eac9f73c3b63cfde14c056f281 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:00:11 +0200 Subject: [PATCH 379/640] Use shorthand attributes in Songpal (#99849) --- .../components/songpal/media_player.py | 31 ++++++------------- 1 file changed, 10 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/songpal/media_player.py b/homeassistant/components/songpal/media_player.py index 2d2c5892636839..79fab9a26517d8 100644 --- a/homeassistant/components/songpal/media_player.py +++ b/homeassistant/components/songpal/media_player.py @@ -110,14 +110,14 @@ def __init__(self, name, device): self._model = None self._state = False - self._available = False + self._attr_available = False self._initialized = False self._volume_control = None self._volume_min = 0 self._volume_max = 1 self._volume = 0 - self._is_muted = False + self._attr_is_volume_muted = False self._active_source = None self._sources = {} @@ -137,7 +137,7 @@ async def async_activate_websocket(self): async def _volume_changed(volume: VolumeChange): _LOGGER.debug("Volume changed: %s", volume) self._volume = volume.volume - self._is_muted = volume.mute + self._attr_is_volume_muted = volume.mute self.async_write_ha_state() async def _source_changed(content: ContentChange): @@ -161,13 +161,13 @@ async def _try_reconnect(connect: ConnectChange): self._dev.endpoint, ) _LOGGER.debug("Disconnected: %s", connect.exception) - self._available = False + self._attr_available = False self.async_write_ha_state() # Try to reconnect forever, a successful reconnect will initialize # the websocket connection again. delay = INITIAL_RETRY_DELAY - while not self._available: + while not self._attr_available: _LOGGER.debug("Trying to reconnect in %s seconds", delay) await asyncio.sleep(delay) @@ -220,11 +220,6 @@ def device_info(self) -> DeviceInfo: sw_version=self._sysinfo.version, ) - @property - def available(self): - """Return availability of the device.""" - return self._available - async def async_set_sound_setting(self, name, value): """Change a setting on the device.""" _LOGGER.debug("Calling set_sound_setting with %s: %s", name, value) @@ -243,7 +238,7 @@ async def async_update(self) -> None: volumes = await self._dev.get_volume_information() if not volumes: _LOGGER.error("Got no volume controls, bailing out") - self._available = False + self._attr_available = False return if len(volumes) > 1: @@ -256,7 +251,7 @@ async def async_update(self) -> None: self._volume_min = volume.minVolume self._volume = volume.volume self._volume_control = volume - self._is_muted = self._volume_control.is_muted + self._attr_is_volume_muted = self._volume_control.is_muted status = await self._dev.get_power() self._state = status.status @@ -273,11 +268,11 @@ async def async_update(self) -> None: _LOGGER.debug("Active source: %s", self._active_source) - self._available = True + self._attr_available = True except SongpalException as ex: _LOGGER.error("Unable to update: %s", ex) - self._available = False + self._attr_available = False async def async_select_source(self, source: str) -> None: """Select source.""" @@ -309,8 +304,7 @@ def source(self): @property def volume_level(self): """Return volume level.""" - volume = self._volume / self._volume_max - return volume + return self._volume / self._volume_max async def async_set_volume_level(self, volume: float) -> None: """Set volume level.""" @@ -354,8 +348,3 @@ async def async_mute_volume(self, mute: bool) -> None: """Mute or unmute the device.""" _LOGGER.debug("Set mute: %s", mute) return await self._volume_control.set_mute(mute) - - @property - def is_volume_muted(self): - """Return whether the device is muted.""" - return self._is_muted From 1ccf9cc400bc48909abbdfc2be0591d783dbb7a8 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:02:29 +0200 Subject: [PATCH 380/640] Use shorthand attributes in Squeezebox (#99863) --- .../components/squeezebox/media_player.py | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/squeezebox/media_player.py b/homeassistant/components/squeezebox/media_player.py index c77126e4377869..03457c6a5c01e8 100644 --- a/homeassistant/components/squeezebox/media_player.py +++ b/homeassistant/components/squeezebox/media_player.py @@ -1,6 +1,7 @@ """Support for interfacing to the Logitech SqueezeBox API.""" from __future__ import annotations +from datetime import datetime import json import logging from typing import Any @@ -238,17 +239,17 @@ class SqueezeBoxEntity(MediaPlayerEntity): ) _attr_has_entity_name = True _attr_name = None + _last_update: datetime | None = None + _attr_available = True def __init__(self, player): """Initialize the SqueezeBox device.""" self._player = player - self._last_update = None self._query_result = {} - self._available = True self._remove_dispatcher = None - self._attr_unique_id = format_mac(self._player.player_id) + self._attr_unique_id = format_mac(player.player_id) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._attr_unique_id)}, name=self._player.name + identifiers={(DOMAIN, self._attr_unique_id)}, name=player.name ) @property @@ -262,16 +263,11 @@ def extra_state_attributes(self): return squeezebox_attr - @property - def available(self): - """Return True if device connected to LMS server.""" - return self._available - @callback def rediscovered(self, unique_id, connected): """Make a player available again.""" if unique_id == self.unique_id and connected: - self._available = True + self._attr_available = True _LOGGER.info("Player %s is available again", self.name) self._remove_dispatcher() @@ -287,14 +283,14 @@ def state(self) -> MediaPlayerState | None: async def async_update(self) -> None: """Update the Player() object.""" # only update available players, newly available players will be rediscovered and marked available - if self._available: + if self._attr_available: last_media_position = self.media_position await self._player.async_update() if self.media_position != last_media_position: self._last_update = utcnow() if self._player.connected is False: _LOGGER.info("Player %s is not available", self.name) - self._available = False + self._attr_available = False # start listening for restored players self._remove_dispatcher = async_dispatcher_connect( From b5275016d453ce7173a786c1c3fb8542948a57e2 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:08:18 +0200 Subject: [PATCH 381/640] Use shorthand attributes in Twinkly (#99891) --- homeassistant/components/twinkly/light.py | 63 ++++++----------------- 1 file changed, 15 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/twinkly/light.py b/homeassistant/components/twinkly/light.py index 5ddd22c8a23b14..66f764f17f6b4c 100644 --- a/homeassistant/components/twinkly/light.py +++ b/homeassistant/components/twinkly/light.py @@ -2,7 +2,6 @@ from __future__ import annotations import asyncio -from collections.abc import Mapping import logging from typing import Any @@ -62,6 +61,8 @@ async def async_setup_entry( class TwinklyLight(LightEntity): """Implementation of the light for the Twinkly service.""" + _attr_icon = "mdi:string-lights" + def __init__( self, conf: ConfigEntry, @@ -69,7 +70,7 @@ def __init__( device_info, ) -> None: """Initialize a TwinklyLight entity.""" - self._id = conf.data[CONF_ID] + self._attr_unique_id: str = conf.data[CONF_ID] self._conf = conf if device_info.get(DEV_LED_PROFILE) == DEV_PROFILE_RGBW: @@ -93,64 +94,30 @@ def __init__( self._client = client # Set default state before any update - self._is_on = False - self._is_available = False - self._attributes: dict[Any, Any] = {} + self._attr_is_on = False + self._attr_available = False self._current_movie: dict[Any, Any] = {} self._movies: list[Any] = [] self._software_version = "" # We guess that most devices are "new" and support effects self._attr_supported_features = LightEntityFeature.EFFECT - @property - def available(self) -> bool: - """Get a boolean which indicates if this entity is currently available.""" - return self._is_available - - @property - def unique_id(self) -> str | None: - """Id of the device.""" - return self._id - @property def name(self) -> str: """Name of the device.""" return self._name if self._name else "Twinkly light" - @property - def model(self) -> str: - """Name of the device.""" - return self._model - - @property - def icon(self) -> str: - """Icon of the device.""" - return "mdi:string-lights" - @property def device_info(self) -> DeviceInfo | None: """Get device specific attributes.""" return DeviceInfo( - identifiers={(DOMAIN, self._id)}, + identifiers={(DOMAIN, self._attr_unique_id)}, manufacturer="LEDWORKS", - model=self.model, + model=self._model, name=self.name, sw_version=self._software_version, ) - @property - def is_on(self) -> bool: - """Return true if light is on.""" - return self._is_on - - @property - def extra_state_attributes(self) -> Mapping[str, Any]: - """Return device specific state attributes.""" - - attributes = self._attributes - - return attributes - @property def effect(self) -> str | None: """Return the current effect.""" @@ -246,7 +213,7 @@ async def async_turn_on(self, **kwargs: Any) -> None: await self._client.set_current_movie(int(movie_id)) await self._client.set_mode("movie") self._client.default_mode = "movie" - if not self._is_on: + if not self._attr_is_on: await self._client.turn_on() async def async_turn_off(self, **kwargs: Any) -> None: @@ -258,7 +225,7 @@ async def async_update(self) -> None: _LOGGER.debug("Updating '%s'", self._client.host) try: - self._is_on = await self._client.is_on() + self._attr_is_on = await self._client.is_on() brightness = await self._client.get_brightness() brightness_value = ( @@ -266,7 +233,7 @@ async def async_update(self) -> None: ) self._attr_brightness = ( - int(round(brightness_value * 2.55)) if self._is_on else 0 + int(round(brightness_value * 2.55)) if self._attr_is_on else 0 ) device_info = await self._client.get_details() @@ -289,7 +256,7 @@ async def async_update(self) -> None: self._conf, data={ CONF_HOST: self._client.host, # this cannot change - CONF_ID: self._id, # this cannot change + CONF_ID: self._attr_unique_id, # this cannot change CONF_NAME: self._name, CONF_MODEL: self._model, }, @@ -299,20 +266,20 @@ async def async_update(self) -> None: await self.async_update_movies() await self.async_update_current_movie() - if not self._is_available: + if not self._attr_available: _LOGGER.info("Twinkly '%s' is now available", self._client.host) # We don't use the echo API to track the availability since # we already have to pull the device to get its state. - self._is_available = True + self._attr_available = True except (asyncio.TimeoutError, ClientError): # We log this as "info" as it's pretty common that the Christmas # light are not reachable in July - if self._is_available: + if self._attr_available: _LOGGER.info( "Twinkly '%s' is not reachable (client error)", self._client.host ) - self._is_available = False + self._attr_available = False async def async_update_movies(self) -> None: """Update the list of movies (effects).""" From 76c3a638c45bfd0ef2387e2318343f8653fc8fcf Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:17:57 +0200 Subject: [PATCH 382/640] Use shorthand attributes in Smarttub (#99839) --- .../components/smarttub/binary_sensor.py | 16 ++-------- homeassistant/components/smarttub/climate.py | 6 +--- homeassistant/components/smarttub/entity.py | 31 ++++++------------- homeassistant/components/smarttub/light.py | 14 ++------- homeassistant/components/smarttub/switch.py | 6 +--- 5 files changed, 17 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/smarttub/binary_sensor.py b/homeassistant/components/smarttub/binary_sensor.py index a1159bcc0efd36..99037cd623c858 100644 --- a/homeassistant/components/smarttub/binary_sensor.py +++ b/homeassistant/components/smarttub/binary_sensor.py @@ -76,19 +76,13 @@ class SmartTubOnline(SmartTubSensorBase, BinarySensorEntity): """A binary sensor indicating whether the spa is currently online (connected to the cloud).""" _attr_device_class = BinarySensorDeviceClass.CONNECTIVITY + # This seems to be very noisy and not generally useful, so disable by default. + _attr_entity_registry_enabled_default = False def __init__(self, coordinator, spa): """Initialize the entity.""" super().__init__(coordinator, spa, "Online", "online") - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry. - - This seems to be very noisy and not generally useful, so disable by default. - """ - return False - @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -108,11 +102,7 @@ def __init__(self, coordinator, spa, reminder): f"{reminder.name.title()} Reminder", ) self.reminder_id = reminder.id - - @property - def unique_id(self): - """Return a unique id for this sensor.""" - return f"{self.spa.id}-reminder-{self.reminder_id}" + self._attr_unique_id = f"{spa.id}-reminder-{reminder.id}" @property def reminder(self) -> SpaReminder: diff --git a/homeassistant/components/smarttub/climate.py b/homeassistant/components/smarttub/climate.py index a938bde6fd15f7..b2d4fbf17c4641 100644 --- a/homeassistant/components/smarttub/climate.py +++ b/homeassistant/components/smarttub/climate.py @@ -64,6 +64,7 @@ class SmartTubThermostat(SmartTubEntity, ClimateEntity): ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE ) _attr_temperature_unit = UnitOfTemperature.CELSIUS + _attr_preset_modes = list(PRESET_MODES.values()) def __init__(self, coordinator, spa): """Initialize the entity.""" @@ -104,11 +105,6 @@ def preset_mode(self): """Return the current preset mode.""" return PRESET_MODES[self.spa_status.heat_mode] - @property - def preset_modes(self): - """Return the available preset modes.""" - return list(PRESET_MODES.values()) - @property def current_temperature(self): """Return the current water temperature.""" diff --git a/homeassistant/components/smarttub/entity.py b/homeassistant/components/smarttub/entity.py index 7f2a739c26e0b8..6e6cb00a7d3b7a 100644 --- a/homeassistant/components/smarttub/entity.py +++ b/homeassistant/components/smarttub/entity.py @@ -25,27 +25,14 @@ def __init__( super().__init__(coordinator) self.spa = spa - self._entity_name = entity_name - - @property - def unique_id(self) -> str: - """Return a unique id for the entity.""" - return f"{self.spa.id}-{self._entity_name}" - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.spa.id)}, - manufacturer=self.spa.brand, - model=self.spa.model, + self._attr_unique_id = f"{spa.id}-{entity_name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, spa.id)}, + manufacturer=spa.brand, + model=spa.model, ) - - @property - def name(self) -> str: - """Return the name of the entity.""" spa_name = get_spa_name(self.spa) - return f"{spa_name} {self._entity_name}" + self._attr_name = f"{spa_name} {entity_name}" @property def spa_status(self) -> smarttub.SpaState: @@ -57,12 +44,12 @@ def spa_status(self) -> smarttub.SpaState: class SmartTubSensorBase(SmartTubEntity): """Base class for SmartTub sensors.""" - def __init__(self, coordinator, spa, sensor_name, attr_name): + def __init__(self, coordinator, spa, sensor_name, state_key): """Initialize the entity.""" super().__init__(coordinator, spa, sensor_name) - self._attr_name = attr_name + self._state_key = state_key @property def _state(self): """Retrieve the underlying state from the spa.""" - return getattr(self.spa_status, self._attr_name) + return getattr(self.spa_status, self._state_key) diff --git a/homeassistant/components/smarttub/light.py b/homeassistant/components/smarttub/light.py index f7e229449e04cd..d89cdba336758d 100644 --- a/homeassistant/components/smarttub/light.py +++ b/homeassistant/components/smarttub/light.py @@ -53,23 +53,15 @@ def __init__(self, coordinator, light): """Initialize the entity.""" super().__init__(coordinator, light.spa, "light") self.light_zone = light.zone + self._attr_unique_id = f"{super().unique_id}-{light.zone}" + spa_name = get_spa_name(self.spa) + self._attr_name = f"{spa_name} Light {light.zone}" @property def light(self) -> SpaLight: """Return the underlying SpaLight object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_LIGHTS][self.light_zone] - @property - def unique_id(self) -> str: - """Return a unique ID for this light entity.""" - return f"{super().unique_id}-{self.light_zone}" - - @property - def name(self) -> str: - """Return a name for this light entity.""" - spa_name = get_spa_name(self.spa) - return f"{spa_name} Light {self.light_zone}" - @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/smarttub/switch.py b/homeassistant/components/smarttub/switch.py index e105963bc01b22..aeeca46aaa9135 100644 --- a/homeassistant/components/smarttub/switch.py +++ b/homeassistant/components/smarttub/switch.py @@ -38,17 +38,13 @@ def __init__(self, coordinator, pump: SpaPump) -> None: super().__init__(coordinator, pump.spa, "pump") self.pump_id = pump.id self.pump_type = pump.type + self._attr_unique_id = f"{super().unique_id}-{pump.id}" @property def pump(self) -> SpaPump: """Return the underlying SpaPump object for this entity.""" return self.coordinator.data[self.spa.id][ATTR_PUMPS][self.pump_id] - @property - def unique_id(self) -> str: - """Return a unique ID for this pump entity.""" - return f"{super().unique_id}-{self.pump_id}" - @property def name(self) -> str: """Return a name for this pump entity.""" From 6b265120b3f12185dc04b3fcc2ebe963836dd10d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 15:22:37 +0200 Subject: [PATCH 383/640] Fix entity name attribute on mqtt entity is not removed on update (#100187) Fix entity name attribute is not remove on update --- homeassistant/components/mqtt/mixins.py | 5 +++ tests/components/mqtt/test_mixins.py | 60 ++++++++++++++++++++++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index ceccfa5adc88ac..795eb30e8e2715 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -1137,6 +1137,11 @@ def _set_entity_name(self, config: ConfigType) -> None: elif not self._default_to_device_class_name(): # Assign the default name self._attr_name = self._default_name + elif hasattr(self, "_attr_name"): + # An entity name was not set in the config + # don't set the name attribute and derive + # the name from the device_class + delattr(self, "_attr_name") if CONF_DEVICE in config: device_name: str if CONF_NAME not in config[CONF_DEVICE]: diff --git a/tests/components/mqtt/test_mixins.py b/tests/components/mqtt/test_mixins.py index 0647721b4d019c..1ca9bf07d72a6b 100644 --- a/tests/components/mqtt/test_mixins.py +++ b/tests/components/mqtt/test_mixins.py @@ -7,6 +7,7 @@ from homeassistant.components import mqtt, sensor from homeassistant.components.mqtt.sensor import DEFAULT_NAME as DEFAULT_SENSOR_NAME from homeassistant.const import ( + ATTR_FRIENDLY_NAME, EVENT_HOMEASSISTANT_STARTED, EVENT_STATE_CHANGED, Platform, @@ -324,7 +325,6 @@ async def test_default_entity_and_device_name( This is a test helper for the _setup_common_attributes_from_config mixin. """ - # mqtt_mock = await mqtt_mock_entry() events = async_capture_events(hass, ir.EVENT_REPAIRS_ISSUE_REGISTRY_UPDATED) hass.state = CoreState.starting @@ -352,3 +352,61 @@ async def test_default_entity_and_device_name( # Assert that an issues ware registered assert len(events) == issue_events + + +@patch("homeassistant.components.mqtt.PLATFORMS", [Platform.BINARY_SENSOR]) +async def test_name_attribute_is_set_or_not( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, +) -> None: + """Test frendly name with device_class set. + + This is a test helper for the _setup_common_attributes_from_config mixin. + """ + await mqtt_mock_entry() + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": "Gate", "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Gate" + + # Remove the name in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) == "Door" + + # Set the name to `null` in a discovery update + async_fire_mqtt_message( + hass, + "homeassistant/binary_sensor/bla/config", + '{ "name": null, "state_topic": "test-topic", "device_class": "door", ' + '"object_id": "gate",' + '"device": {"identifiers": "very_unique", "name": "xyz_door_sensor"}' + "}", + ) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.gate") + + assert state is not None + assert state.attributes.get(ATTR_FRIENDLY_NAME) is None From e143bdf2f575f8732ea7d810f50968db572a2220 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 15:23:12 +0200 Subject: [PATCH 384/640] Use shorthand attributes in Vera (#99893) --- .../components/vera/binary_sensor.py | 10 ++---- homeassistant/components/vera/climate.py | 6 +--- homeassistant/components/vera/light.py | 36 ++++++------------- homeassistant/components/vera/lock.py | 16 +++------ homeassistant/components/vera/scene.py | 7 +--- homeassistant/components/vera/sensor.py | 25 +++++-------- homeassistant/components/vera/switch.py | 14 +++----- 7 files changed, 34 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/vera/binary_sensor.py b/homeassistant/components/vera/binary_sensor.py index 57b47e6c742467..82c7d187b8882a 100644 --- a/homeassistant/components/vera/binary_sensor.py +++ b/homeassistant/components/vera/binary_sensor.py @@ -32,20 +32,16 @@ async def async_setup_entry( class VeraBinarySensor(VeraDevice[veraApi.VeraBinarySensor], BinarySensorEntity): """Representation of a Vera Binary Sensor.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraBinarySensor, controller_data: ControllerData ) -> None: """Initialize the binary_sensor.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def is_on(self) -> bool | None: - """Return true if sensor is on.""" - return self._state - def update(self) -> None: """Get the latest data and update the state.""" super().update() - self._state = self.vera_device.is_tripped + self._attr_is_on = self.vera_device.is_tripped diff --git a/homeassistant/components/vera/climate.py b/homeassistant/components/vera/climate.py index 164da079ac1391..f58ae083f72c61 100644 --- a/homeassistant/components/vera/climate.py +++ b/homeassistant/components/vera/climate.py @@ -46,6 +46,7 @@ class VeraThermostat(VeraDevice[veraApi.VeraThermostat], ClimateEntity): """Representation of a Vera Thermostat.""" _attr_hvac_modes = SUPPORT_HVAC + _attr_fan_modes = FAN_OPERATION_LIST _attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE ) @@ -79,11 +80,6 @@ def fan_mode(self) -> str | None: return FAN_ON return FAN_AUTO - @property - def fan_modes(self) -> list[str] | None: - """Return a list of available fan modes.""" - return FAN_OPERATION_LIST - def set_fan_mode(self, fan_mode: str) -> None: """Set new target temperature.""" if fan_mode == FAN_ON: diff --git a/homeassistant/components/vera/light.py b/homeassistant/components/vera/light.py index fa017be475e5e5..c76cd76ad194f1 100644 --- a/homeassistant/components/vera/light.py +++ b/homeassistant/components/vera/light.py @@ -41,31 +41,22 @@ async def async_setup_entry( class VeraLight(VeraDevice[veraApi.VeraDimmer], LightEntity): """Representation of a Vera Light, including dimmable.""" + _attr_is_on = False + _attr_hs_color: tuple[float, float] | None = None + _attr_brightness: int | None = None + def __init__( self, vera_device: veraApi.VeraDimmer, controller_data: ControllerData ) -> None: """Initialize the light.""" - self._state = False - self._color: tuple[float, float] | None = None - self._brightness = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def brightness(self) -> int | None: - """Return the brightness of the light.""" - return self._brightness - - @property - def hs_color(self) -> tuple[float, float] | None: - """Return the color of the light.""" - return self._color - @property def color_mode(self) -> ColorMode: """Return the color mode of the light.""" if self.vera_device.is_dimmable: - if self._color: + if self._attr_hs_color: return ColorMode.HS return ColorMode.BRIGHTNESS return ColorMode.ONOFF @@ -77,7 +68,7 @@ def supported_color_modes(self) -> set[ColorMode]: def turn_on(self, **kwargs: Any) -> None: """Turn the light on.""" - if ATTR_HS_COLOR in kwargs and self._color: + if ATTR_HS_COLOR in kwargs and self._attr_hs_color: rgb = color_util.color_hs_to_RGB(*kwargs[ATTR_HS_COLOR]) self.vera_device.set_color(rgb) elif ATTR_BRIGHTNESS in kwargs and self.vera_device.is_dimmable: @@ -85,27 +76,22 @@ def turn_on(self, **kwargs: Any) -> None: else: self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state(True) def turn_off(self, **kwargs: Any) -> None: """Turn the light off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Call to update state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() if self.vera_device.is_dimmable: # If it is dimmable, both functions exist. In case color # is not supported, it will return None - self._brightness = self.vera_device.get_brightness() + self._attr_brightness = self.vera_device.get_brightness() rgb = self.vera_device.get_color() - self._color = color_util.color_RGB_to_hs(*rgb) if rgb else None + self._attr_hs_color = color_util.color_RGB_to_hs(*rgb) if rgb else None diff --git a/homeassistant/components/vera/lock.py b/homeassistant/components/vera/lock.py index 50710030b8f2af..8994076ca312e8 100644 --- a/homeassistant/components/vera/lock.py +++ b/homeassistant/components/vera/lock.py @@ -7,7 +7,7 @@ from homeassistant.components.lock import ENTITY_ID_FORMAT, LockEntity from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_LOCKED, STATE_UNLOCKED, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,24 +41,18 @@ def __init__( self, vera_device: veraApi.VeraLock, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state: str | None = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def lock(self, **kwargs: Any) -> None: """Lock the device.""" self.vera_device.lock() - self._state = STATE_LOCKED + self._attr_is_locked = True def unlock(self, **kwargs: Any) -> None: """Unlock the device.""" self.vera_device.unlock() - self._state = STATE_UNLOCKED - - @property - def is_locked(self) -> bool | None: - """Return true if device is on.""" - return self._state == STATE_LOCKED + self._attr_is_locked = False @property def extra_state_attributes(self) -> dict[str, Any] | None: @@ -91,6 +85,4 @@ def changed_by(self) -> str | None: def update(self) -> None: """Update state by the Vera device callback.""" - self._state = ( - STATE_LOCKED if self.vera_device.is_locked(True) else STATE_UNLOCKED - ) + self._attr_is_locked = self.vera_device.is_locked(True) diff --git a/homeassistant/components/vera/scene.py b/homeassistant/components/vera/scene.py index c1381f488dd9ea..daa3a6fc530016 100644 --- a/homeassistant/components/vera/scene.py +++ b/homeassistant/components/vera/scene.py @@ -37,7 +37,7 @@ def __init__( self.vera_scene = vera_scene self.controller = controller_data.controller - self._name = self.vera_scene.name + self._attr_name = self.vera_scene.name # Append device id to prevent name clashes in HA. self.vera_id = VERA_ID_FORMAT.format( slugify(vera_scene.name), vera_scene.scene_id @@ -51,11 +51,6 @@ def activate(self, **kwargs: Any) -> None: """Activate the scene.""" self.vera_scene.activate() - @property - def name(self) -> str: - """Return the name of the scene.""" - return self._name - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the scene.""" diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index b493f9aac3deb7..942ebc77acdbab 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -21,7 +21,6 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import StateType from . import VeraDevice from .common import ControllerData, get_controller_data @@ -52,17 +51,11 @@ def __init__( self, vera_device: veraApi.VeraSensor, controller_data: ControllerData ) -> None: """Initialize the sensor.""" - self.current_value: StateType = None self._temperature_units: str | None = None self.last_changed_time = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - @property - def native_value(self) -> StateType: - """Return the name of the sensor.""" - return self.current_value - @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" @@ -96,7 +89,7 @@ def update(self) -> None: """Update the state.""" super().update() if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - self.current_value = self.vera_device.temperature + self._attr_native_value = self.vera_device.temperature vera_temp_units = self.vera_device.vera_controller.temperature_units @@ -106,24 +99,24 @@ def update(self) -> None: self._temperature_units = UnitOfTemperature.CELSIUS elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - self.current_value = self.vera_device.light + self._attr_native_value = self.vera_device.light elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - self.current_value = self.vera_device.humidity + self._attr_native_value = self.vera_device.humidity elif self.vera_device.category == veraApi.CATEGORY_SCENE_CONTROLLER: controller = cast(veraApi.VeraSceneController, self.vera_device) value = controller.get_last_scene_id(True) time = controller.get_last_scene_time(True) if time == self.last_changed_time: - self.current_value = None + self._attr_native_value = None else: - self.current_value = value + self._attr_native_value = value self.last_changed_time = time elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: - self.current_value = self.vera_device.power + self._attr_native_value = self.vera_device.power elif self.vera_device.is_trippable: tripped = self.vera_device.is_tripped - self.current_value = "Tripped" if tripped else "Not Tripped" + self._attr_native_value = "Tripped" if tripped else "Not Tripped" else: - self.current_value = "Unknown" + self._attr_native_value = "Unknown" diff --git a/homeassistant/components/vera/switch.py b/homeassistant/components/vera/switch.py index b146ed39adefb1..011f777b1b2f04 100644 --- a/homeassistant/components/vera/switch.py +++ b/homeassistant/components/vera/switch.py @@ -34,32 +34,28 @@ async def async_setup_entry( class VeraSwitch(VeraDevice[veraApi.VeraSwitch], SwitchEntity): """Representation of a Vera Switch.""" + _attr_is_on = False + def __init__( self, vera_device: veraApi.VeraSwitch, controller_data: ControllerData ) -> None: """Initialize the Vera device.""" - self._state = False VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) def turn_on(self, **kwargs: Any) -> None: """Turn device on.""" self.vera_device.switch_on() - self._state = True + self._attr_is_on = True self.schedule_update_ha_state() def turn_off(self, **kwargs: Any) -> None: """Turn device off.""" self.vera_device.switch_off() - self._state = False + self._attr_is_on = False self.schedule_update_ha_state() - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self._state - def update(self) -> None: """Update device state.""" super().update() - self._state = self.vera_device.is_switched_on() + self._attr_is_on = self.vera_device.is_switched_on() From fabb098ec3b72d4874609ccbd6a6963c5f839dcd Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 15:39:11 +0200 Subject: [PATCH 385/640] Simplify WS command entity/source (#99439) --- .../components/websocket_api/commands.py | 57 +++++------- .../components/websocket_api/test_commands.py | 89 +------------------ 2 files changed, 26 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index ea21b7b5ebadd9..66866197081daf 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.auth.models import User -from homeassistant.auth.permissions.const import CAT_ENTITIES, POLICY_READ +from homeassistant.auth.permissions.const import POLICY_READ from homeassistant.const import ( EVENT_STATE_CHANGED, MATCH_ALL, @@ -52,7 +52,6 @@ from . import const, decorators, messages from .connection import ActiveConnection -from .const import ERR_NOT_FOUND from .messages import construct_event_message, construct_result_message ALL_SERVICE_DESCRIPTIONS_JSON_CACHE = "websocket_api_all_service_descriptions_json" @@ -596,47 +595,35 @@ def _template_listener( hass.loop.call_soon_threadsafe(info.async_refresh) +def _serialize_entity_sources( + entity_infos: dict[str, dict[str, str]] +) -> dict[str, Any]: + """Prepare a websocket response from a dict of entity sources.""" + result = {} + for entity_id, entity_info in entity_infos.items(): + result[entity_id] = {"domain": entity_info["domain"]} + return result + + @callback -@decorators.websocket_command( - {vol.Required("type"): "entity/source", vol.Optional("entity_id"): [cv.entity_id]} -) +@decorators.websocket_command({vol.Required("type"): "entity/source"}) def handle_entity_source( hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any] ) -> None: """Handle entity source command.""" - raw_sources = entity.entity_sources(hass) + all_entity_sources = entity.entity_sources(hass) entity_perm = connection.user.permissions.check_entity - if "entity_id" not in msg: - if connection.user.permissions.access_all_entities(POLICY_READ): - sources = raw_sources - else: - sources = { - entity_id: source - for entity_id, source in raw_sources.items() - if entity_perm(entity_id, POLICY_READ) - } - - connection.send_result(msg["id"], sources) - return - - sources = {} - - for entity_id in msg["entity_id"]: - if not entity_perm(entity_id, POLICY_READ): - raise Unauthorized( - context=connection.context(msg), - permission=POLICY_READ, - perm_category=CAT_ENTITIES, - ) - - if (source := raw_sources.get(entity_id)) is None: - connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found") - return - - sources[entity_id] = source + if connection.user.permissions.access_all_entities(POLICY_READ): + entity_sources = all_entity_sources + else: + entity_sources = { + entity_id: source + for entity_id, source in all_entity_sources.items() + if entity_perm(entity_id, POLICY_READ) + } - connection.send_result(msg["id"], sources) + connection.send_result(msg["id"], _serialize_entity_sources(entity_sources)) @decorators.websocket_command( diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index b1b2027c65d5e8..8cd5e23ce29ded 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -20,7 +20,7 @@ from homeassistant.const import SIGNAL_BOOTSTRAP_INTEGRATIONS from homeassistant.core import Context, HomeAssistant, State, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import device_registry as dr, entity +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.loader import async_get_integration from homeassistant.setup import DATA_SETUP_TIME, async_setup_component @@ -1941,76 +1941,10 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_1": {"domain": "test_platform"}, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch one - await websocket_client.send_json( - {"id": 7, "type": "entity/source", "entity_id": ["test_domain.entity_2"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 7 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch two - await websocket_client.send_json( - { - "id": 8, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.entity_1"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 8 - assert msg["type"] == const.TYPE_RESULT - assert msg["success"] - assert msg["result"] == { - "test_domain.entity_1": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, - } - - # Fetch non existing - await websocket_client.send_json( - { - "id": 9, - "type": "entity/source", - "entity_id": ["test_domain.entity_2", "test_domain.non_existing"], - } - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 9 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_NOT_FOUND - # Mock policy hass_admin_user.groups = [] hass_admin_user.mock_policy( @@ -2025,24 +1959,9 @@ async def test_entity_source_admin( assert msg["type"] == const.TYPE_RESULT assert msg["success"] assert msg["result"] == { - "test_domain.entity_2": { - "custom_component": False, - "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, - }, + "test_domain.entity_2": {"domain": "test_platform"}, } - # Fetch unauthorized - await websocket_client.send_json( - {"id": 11, "type": "entity/source", "entity_id": ["test_domain.entity_1"]} - ) - - msg = await websocket_client.receive_json() - assert msg["id"] == 11 - assert msg["type"] == const.TYPE_RESULT - assert not msg["success"] - assert msg["error"]["code"] == const.ERR_UNAUTHORIZED - async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: """Test subscribing to a trigger.""" From 1e2b0b65afc075d0e73cd4a75d3f58980a4ac52d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Tue, 12 Sep 2023 15:58:25 +0200 Subject: [PATCH 386/640] Bump hass-nabucasa from 0.70.0 to 0.71.0 (#100193) Bump hass-nabucasa from 0.70.0 to 0.71.1 --- homeassistant/components/cloud/__init__.py | 2 -- homeassistant/components/cloud/const.py | 1 - homeassistant/components/cloud/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_init.py | 1 - 7 files changed, 4 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 40e5f264caf443..4dc242376d9d9e 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -47,7 +47,6 @@ CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_RELAYER_SERVER, - CONF_REMOTE_SNI_SERVER, CONF_REMOTESTATE_SERVER, CONF_SERVICEHANDLERS_SERVER, CONF_THINGTALK_SERVER, @@ -115,7 +114,6 @@ vol.Optional(CONF_ALEXA_SERVER): str, vol.Optional(CONF_CLOUDHOOK_SERVER): str, vol.Optional(CONF_RELAYER_SERVER): str, - vol.Optional(CONF_REMOTE_SNI_SERVER): str, vol.Optional(CONF_REMOTESTATE_SERVER): str, vol.Optional(CONF_THINGTALK_SERVER): str, vol.Optional(CONF_SERVICEHANDLERS_SERVER): str, diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 7aa39efbf07204..bd9d61cde16522 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -55,7 +55,6 @@ CONF_ALEXA_SERVER = "alexa_server" CONF_CLOUDHOOK_SERVER = "cloudhook_server" CONF_RELAYER_SERVER = "relayer_server" -CONF_REMOTE_SNI_SERVER = "remote_sni_server" CONF_REMOTESTATE_SERVER = "remotestate_server" CONF_THINGTALK_SERVER = "thingtalk_server" CONF_SERVICEHANDLERS_SERVER = "servicehandlers_server" diff --git a/homeassistant/components/cloud/manifest.json b/homeassistant/components/cloud/manifest.json index a8e28d6629124a..fe0628f1886ae2 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.70.0"] + "requirements": ["hass-nabucasa==0.71.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 5aaf114f1b8d92..a5fb3856c05da7 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -19,7 +19,7 @@ cryptography==41.0.3 dbus-fast==2.6.0 fnv-hash-fast==0.4.1 ha-av==10.1.1 -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 hassil==1.2.5 home-assistant-bluetooth==1.10.3 home-assistant-frontend==20230911.0 diff --git a/requirements_all.txt b/requirements_all.txt index ce2842b6316de4..3c5ef0694b27c9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -962,7 +962,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.splunk hass-splunk==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 14831d4fa592a6..ec4ec481af6e51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -760,7 +760,7 @@ ha-philipsjs==3.1.0 habitipy==0.2.0 # homeassistant.components.cloud -hass-nabucasa==0.70.0 +hass-nabucasa==0.71.0 # homeassistant.components.conversation hassil==1.2.5 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 28b531b608c5c0..e12775d5a4a5f4 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -32,7 +32,6 @@ async def test_constructor_loads_info_from_config(hass: HomeAssistant) -> None: "relayer_server": "test-relayer-server", "accounts_server": "test-acounts-server", "cloudhook_server": "test-cloudhook-server", - "remote_sni_server": "test-remote-sni-server", "alexa_server": "test-alexa-server", "acme_server": "test-acme-server", "remotestate_server": "test-remotestate-server", From 2b62285eeea5fce546337114066957b45895e6a7 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Tue, 12 Sep 2023 09:59:12 -0400 Subject: [PATCH 387/640] Fix addon slug validation (#100070) * Fix addon slug validation * Don't redefine compile --- homeassistant/components/hassio/__init__.py | 5 ++- tests/components/hassio/test_init.py | 35 +++++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 72fb5ce5110eeb..270309149ef3ce 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -6,6 +6,7 @@ from datetime import datetime, timedelta import logging import os +import re from typing import Any, NamedTuple import voluptuous as vol @@ -149,10 +150,12 @@ SERVICE_RESTORE_FULL = "restore_full" SERVICE_RESTORE_PARTIAL = "restore_partial" +VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) + def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" - value = cv.slug(value) + value = VALID_ADDON_SLUG(value) hass: HomeAssistant | None = None with suppress(HomeAssistantError): diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 31ee73013dad28..48f52ee7c2416e 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -633,6 +633,41 @@ async def test_invalid_service_calls( ) +async def test_addon_service_call_with_complex_slug( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Addon slugs can have ., - and _, confirm that passes validation.""" + supervisor_mock_data = { + "version_latest": "1.0.0", + "version": "1.0.0", + "auto_update": True, + "addons": [ + { + "name": "test.a_1-2", + "slug": "test.a_1-2", + "state": "stopped", + "update_available": False, + "version": "1.0.0", + "version_latest": "1.0.0", + "repository": "core", + "icon": False, + }, + ], + } + with patch.dict(os.environ, MOCK_ENVIRON), patch( + "homeassistant.components.hassio.HassIO.is_connected", + return_value=None, + ), patch( + "homeassistant.components.hassio.HassIO.get_supervisor_info", + return_value=supervisor_mock_data, + ): + assert await async_setup_component(hass, "hassio", {}) + await hass.async_block_till_done() + + await hass.services.async_call("hassio", "addon_start", {"addon": "test.a_1-2"}) + + async def test_service_calls_core( hassio_env, hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ) -> None: From 198532d51d5d6a59cb7fddad329330aa75a5fcac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Tue, 12 Sep 2023 15:59:54 +0200 Subject: [PATCH 388/640] Airthings BLE unique id migration (#99832) * Fix sensor unique id * Add sensor identifiers * Migrate entities to new unique id * Fix linting issues * Fix crash when migrating entity fails * Change how entities are migrated * Remve debug logging * Remove unneeded async * Remove migration code from init file * Add migration code to sensor.py * Adjust for loops to improve speed * Bugfixes, improve documentation * Remove old comment * Remove unused function parameter * Address PR feedback * Add tests * Improve tests and test data * Refactor test * Update logger level Co-authored-by: J. Nick Koston * Adjust PR comments * Address more PR comments * Address PR comments and adjust tests * Fix PR comment --------- Co-authored-by: J. Nick Koston --- .../components/airthings_ble/sensor.py | 55 ++++- tests/components/airthings_ble/__init__.py | 108 ++++++++- tests/components/airthings_ble/test_sensor.py | 213 ++++++++++++++++++ 3 files changed, 365 insertions(+), 11 deletions(-) create mode 100644 tests/components/airthings_ble/test_sensor.py diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index 4783f3e3b35a7e..b66d6b8f8106cb 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -5,25 +5,35 @@ from airthings_ble import AirthingsDevice -from homeassistant import config_entries from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, ) +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, LIGHT_LUX, PERCENTAGE, EntityCategory, + Platform, UnitOfPressure, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH, DeviceInfo +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import ( + CONNECTION_BLUETOOTH, + DeviceInfo, + async_get as device_async_get, +) from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.entity_registry import ( + RegistryEntry, + async_entries_for_device, + async_get as entity_async_get, +) from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, @@ -107,9 +117,43 @@ } +@callback +def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: + """Migrate entities to new unique ids (with BLE Address).""" + ent_reg = entity_async_get(hass) + unique_id_trailer = f"_{sensor_name}" + new_unique_id = f"{address}{unique_id_trailer}" + if ent_reg.async_get_entity_id(DOMAIN, Platform.SENSOR, new_unique_id): + # New unique id already exists + return + dev_reg = device_async_get(hass) + if not ( + device := dev_reg.async_get_device( + connections={(CONNECTION_BLUETOOTH, address)} + ) + ): + return + entities = async_entries_for_device( + ent_reg, + device_id=device.id, + include_disabled_entities=True, + ) + matching_reg_entry: RegistryEntry | None = None + for entry in entities: + if entry.unique_id.endswith(unique_id_trailer) and ( + not matching_reg_entry or "(" not in entry.unique_id + ): + matching_reg_entry = entry + if not matching_reg_entry: + return + entity_id = matching_reg_entry.entity_id + ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id) + _LOGGER.debug("Migrated entity '%s' to unique id '%s'", entity_id, new_unique_id) + + async def async_setup_entry( hass: HomeAssistant, - entry: config_entries.ConfigEntry, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Airthings BLE sensors.""" @@ -137,6 +181,7 @@ async def async_setup_entry( sensor_value, ) continue + async_migrate(hass, coordinator.data.address, sensor_type) entities.append( AirthingsSensor(coordinator, coordinator.data, sensors_mapping[sensor_type]) ) @@ -165,7 +210,7 @@ def __init__( if identifier := airthings_device.identifier: name += f" ({identifier})" - self._attr_unique_id = f"{name}_{entity_description.key}" + self._attr_unique_id = f"{airthings_device.address}_{entity_description.key}" self._attr_device_info = DeviceInfo( connections={ ( diff --git a/tests/components/airthings_ble/__init__.py b/tests/components/airthings_ble/__init__.py index 0dd78718a30eaa..da0c312bf28bd4 100644 --- a/tests/components/airthings_ble/__init__.py +++ b/tests/components/airthings_ble/__init__.py @@ -5,8 +5,11 @@ from airthings_ble import AirthingsBluetoothDeviceData, AirthingsDevice +from homeassistant.components.airthings_ble.const import DOMAIN from homeassistant.components.bluetooth.models import BluetoothServiceInfoBleak +from homeassistant.helpers.device_registry import CONNECTION_BLUETOOTH +from tests.common import MockConfigEntry, MockEntity from tests.components.bluetooth import generate_advertisement_data, generate_ble_device @@ -36,18 +39,52 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): ) +def patch_airthings_device_update(): + """Patch airthings-ble device.""" + return patch( + "homeassistant.components.airthings_ble.AirthingsBluetoothDeviceData.update_device", + return_value=WAVE_DEVICE_INFO, + ) + + WAVE_SERVICE_INFO = BluetoothServiceInfoBleak( name="cc-cc-cc-cc-cc-cc", address="cc:cc:cc:cc:cc:cc", + device=generate_ble_device( + address="cc:cc:cc:cc:cc:cc", + name="Airthings Wave+", + ), rssi=-61, manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, - service_data={}, - service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], + service_data={ + # Sensor data + "b42e2a68-ade7-11e4-89d3-123b93f75cba": bytearray( + b"\x01\x02\x03\x04\x00\x05\x00\x06\x00\x07\x00\x08\x00\x09\x00\x0A" + ), + # Manufacturer + "00002a29-0000-1000-8000-00805f9b34fb": bytearray(b"Airthings AS"), + # Model + "00002a24-0000-1000-8000-00805f9b34fb": bytearray(b"2930"), + # Identifier + "00002a25-0000-1000-8000-00805f9b34fb": bytearray(b"123456"), + # SW Version + "00002a26-0000-1000-8000-00805f9b34fb": bytearray(b"G-BLE-1.5.3-master+0"), + # HW Version + "00002a27-0000-1000-8000-00805f9b34fb": bytearray(b"REV A"), + # Command + "b42e2d06-ade7-11e4-89d3-123b93f75cba": bytearray(b"\x00"), + }, + service_uuids=[ + "b42e1c08-ade7-11e4-89d3-123b93f75cba", + "b42e2a68-ade7-11e4-89d3-123b93f75cba", + "00002a29-0000-1000-8000-00805f9b34fb", + "00002a24-0000-1000-8000-00805f9b34fb", + "00002a25-0000-1000-8000-00805f9b34fb", + "00002a26-0000-1000-8000-00805f9b34fb", + "00002a27-0000-1000-8000-00805f9b34fb", + "b42e2d06-ade7-11e4-89d3-123b93f75cba", + ], source="local", - device=generate_ble_device( - "cc:cc:cc:cc:cc:cc", - "cc-cc-cc-cc-cc-cc", - ), advertisement=generate_advertisement_data( manufacturer_data={820: b"\xe4/\xa5\xae\t\x00"}, service_uuids=["b42e1c08-ade7-11e4-89d3-123b93f75cba"], @@ -99,3 +136,62 @@ def patch_airthings_ble(return_value=AirthingsDevice, side_effect=None): }, address="cc:cc:cc:cc:cc:cc", ) + +TEMPERATURE_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_temperature", + name="Airthings Wave Plus 123456 Temperature", +) + +HUMIDITY_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_humidity", + name="Airthings Wave Plus (123456) Humidity", +) + +CO2_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_co2", + name="Airthings Wave Plus 123456 CO2", +) + +CO2_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_co2", + name="Airthings Wave Plus (123456) CO2", +) + +VOC_V1 = MockEntity( + unique_id="Airthings Wave Plus 123456_voc", + name="Airthings Wave Plus 123456 CO2", +) + +VOC_V2 = MockEntity( + unique_id="Airthings Wave Plus (123456)_voc", + name="Airthings Wave Plus (123456) VOC", +) + +VOC_V3 = MockEntity( + unique_id="cc:cc:cc:cc:cc:cc_voc", + name="Airthings Wave Plus (123456) VOC", +) + + +def create_entry(hass): + """Create a config entry.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=WAVE_SERVICE_INFO.address, + title="Airthings Wave Plus (123456)", + ) + entry.add_to_hass(hass) + return entry + + +def create_device(hass, entry): + """Create a device for the given entry.""" + device_registry = hass.helpers.device_registry.async_get(hass) + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + connections={(CONNECTION_BLUETOOTH, WAVE_SERVICE_INFO.address)}, + manufacturer="Airthings AS", + name="Airthings Wave Plus (123456)", + model="Wave Plus", + ) + return device diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py new file mode 100644 index 00000000000000..68efd4d25f6315 --- /dev/null +++ b/tests/components/airthings_ble/test_sensor.py @@ -0,0 +1,213 @@ +"""Test the Airthings Wave sensor.""" +import logging + +from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.components.airthings_ble import ( + CO2_V1, + CO2_V2, + HUMIDITY_V2, + TEMPERATURE_V1, + VOC_V1, + VOC_V2, + VOC_V3, + WAVE_DEVICE_INFO, + WAVE_SERVICE_INFO, + create_device, + create_entry, + patch_airthings_device_update, +) +from tests.components.bluetooth import inject_bluetooth_service_info + +_LOGGER = logging.getLogger(__name__) + + +async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v1 (pre 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=TEMPERATURE_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_temperature" + ) + + +async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): + """Verify that we can migrate from v2 (introduced in 2023.9.0) to the latest unique id format.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + sensor = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=HUMIDITY_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(sensor.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_humidity" + ) + + +async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): + """Test if migration works when we have both v1 (pre 2023.9.0) and v2 (introduced in 2023.9.0) unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=CO2_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=CO2_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert ( + entity_registry.async_get(v1.entity_id).unique_id + == WAVE_DEVICE_INFO.address + "_co2" + ) + assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + + +async def test_migration_with_all_unique_ids(hass: HomeAssistant): + """Test if migration works when we have all unique ids.""" + entry = create_entry(hass) + device = create_device(hass, entry) + + assert entry is not None + assert device is not None + + entity_registry = hass.helpers.entity_registry.async_get(hass) + + await hass.async_block_till_done() + + v1 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V1.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v2 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V2.unique_id, + config_entry=entry, + device_id=device.id, + ) + + v3 = entity_registry.async_get_or_create( + domain=DOMAIN, + platform="sensor", + unique_id=VOC_V3.unique_id, + config_entry=entry, + device_id=device.id, + ) + + await hass.async_block_till_done() + assert len(hass.states.async_all()) == 0 + + inject_bluetooth_service_info( + hass, + WAVE_SERVICE_INFO, + ) + + await hass.async_block_till_done() + + with patch_airthings_device_update(): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all()) > 0 + + assert entity_registry.async_get(v1.entity_id).unique_id == v1.unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + assert entity_registry.async_get(v3.entity_id).unique_id == v3.unique_id From 9acca1bf5833cfa000e663f156f13411702c8114 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 16:01:15 +0200 Subject: [PATCH 389/640] Make modbus retry fast on read errors (#99576) * Fast retry on read errors. * Review comments. --- homeassistant/components/modbus/base_platform.py | 4 +++- homeassistant/components/modbus/sensor.py | 7 ++++++- tests/components/modbus/test_sensor.py | 12 ++++-------- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 672250790da028..0db716c3403b8f 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -115,7 +115,9 @@ async def async_update(self, now: datetime | None = None) -> None: def async_run(self) -> None: """Remote start entity.""" self.async_hold(update=False) - self._cancel_call = async_call_later(self.hass, 1, self.async_update) + self._cancel_call = async_call_later( + self.hass, timedelta(milliseconds=100), self.async_update + ) if self._scan_interval > 0: self._cancel_timer = async_track_time_interval( self.hass, self.async_update, timedelta(seconds=self._scan_interval) diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index fe2d4bc415d88a..f2ed504b41b5e5 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -1,7 +1,7 @@ """Support for Modbus Register sensors.""" from __future__ import annotations -from datetime import datetime +from datetime import datetime, timedelta import logging from typing import Any @@ -19,6 +19,7 @@ ) from homeassistant.core import HomeAssistant, callback 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.update_coordinator import ( CoordinatorEntity, @@ -106,12 +107,16 @@ async def async_update(self, now: datetime | None = None) -> None: """Update the state of the sensor.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval + self._cancel_call = None raw_result = await self._hub.async_pb_call( self._slave, self._address, self._count, self._input_type ) if raw_result is None: if self._lazy_errors: self._lazy_errors -= 1 + self._cancel_call = async_call_later( + self.hass, timedelta(seconds=1), self.async_update + ) return self._lazy_errors = self._lazy_error_count self._attr_available = False diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 551398c898bfb1..98fd537f1bfb93 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -946,27 +946,23 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: ], ) @pytest.mark.parametrize( - ("register_words", "do_exception", "start_expect", "end_expect"), + ("register_words", "do_exception"), [ ( [0x8000], True, - "17", - STATE_UNAVAILABLE, ), ], ) async def test_lazy_error_sensor( - hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory, start_expect, end_expect + hass: HomeAssistant, mock_do_cycle: FrozenDateTimeFactory ) -> None: """Run test for sensor.""" hass.states.async_set(ENTITY_ID, 17) await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID).state == start_expect + assert hass.states.get(ENTITY_ID).state == "17" await do_next_cycle(hass, mock_do_cycle, 5) - assert hass.states.get(ENTITY_ID).state == start_expect - await do_next_cycle(hass, mock_do_cycle, 11) - assert hass.states.get(ENTITY_ID).state == end_expect + assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE @pytest.mark.parametrize( From c178388956e2c694f2beec8d0a94e3e8e02be05d Mon Sep 17 00:00:00 2001 From: jan iversen Date: Tue, 12 Sep 2023 16:05:59 +0200 Subject: [PATCH 390/640] Remove modbus pragma no cover and solve nan (#99221) * Remove pragma no cover. * Ruff ! * Review comments. * update test. * Review. * review. * Add slave test. --- .../components/modbus/base_platform.py | 25 ++-- tests/components/modbus/test_sensor.py | 119 +++++++++++++++++- 2 files changed, 131 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 0db716c3403b8f..a3876bbe87c6be 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -190,10 +190,14 @@ def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: registers.reverse() return registers - def __process_raw_value(self, entry: float | int | str) -> float | int | str | None: + def __process_raw_value( + self, entry: float | int | str | bytes + ) -> float | int | str | bytes | 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 val: float | int = self._scale * entry + self._offset if self._min_value is not None and val < self._min_value: return self._min_value @@ -234,14 +238,20 @@ def unpack_structure_result(self, registers: list[int]) -> str | None: if isinstance(v_temp, int) and self._precision == 0: v_result.append(str(v_temp)) elif v_temp is None: - v_result.append("") # pragma: no cover + v_result.append("0") elif v_temp != v_temp: # noqa: PLR0124 # NaN float detection replace with None - v_result.append("nan") # pragma: no cover + v_result.append("0") else: v_result.append(f"{float(v_temp):.{self._precision}f}") 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]) @@ -251,15 +261,10 @@ def unpack_structure_result(self, registers: list[int]) -> str | None: if val_result is None: return None - # NaN float detection replace with None - if val_result != val_result: # noqa: PLR0124 - return None # pragma: no cover if isinstance(val_result, int) and self._precision == 0: return str(val_result) - if isinstance(val_result, str): - if val_result == "nan": - val_result = None # pragma: no cover - return val_result + if isinstance(val_result, bytes): + return val_result.decode() return f"{float(val_result):.{self._precision}f}" diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 98fd537f1bfb93..14bccbafac4650 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -1,4 +1,6 @@ """The tests for the Modbus sensor component.""" +import struct + from freezegun.api import FrozenDateTimeFactory import pytest @@ -654,6 +656,21 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: @pytest.mark.parametrize( ("config_addon", "register_words", "do_exception", "expected"), [ + ( + { + CONF_SLAVE_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), ( { CONF_SLAVE_COUNT: 0, @@ -930,6 +947,65 @@ async def test_wrong_unpack(hass: HomeAssistant, mock_do_cycle) -> None: assert hass.states.get(ENTITY_ID).state == STATE_UNAVAILABLE +@pytest.mark.parametrize( + "do_config", + [ + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_SCAN_INTERVAL: 1, + }, + ], + }, + ], +) +@pytest.mark.parametrize( + ("config_addon", "register_words", "expected"), + [ + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6E61, 0x6E00], + STATE_UNAVAILABLE, + ), + ( + { + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_COUNT: 2, + CONF_STRUCTURE: "4s", + }, + [0x6161, 0x6100], + "aaa\x00", + ), + ], +) +async def test_unpack_ok(hass: HomeAssistant, mock_do_cycle, expected) -> None: + """Run test for sensor.""" + assert hass.states.get(ENTITY_ID).state == expected + + @pytest.mark.parametrize( "do_config", [ @@ -989,10 +1065,35 @@ async def test_lazy_error_sensor( CONF_DATA_TYPE: DataType.CUSTOM, CONF_STRUCTURE: ">4f", }, - # floats: 7.931250095367432, 10.600000381469727, + # floats: nan, 10.600000381469727, # 1.000879611487865e-28, 10.566553115844727 - [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], - "7.93,10.60,0.00,10.57", + [ + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + 0x4129, + 0x999A, + 0x10FD, + 0xC0CD, + 0x4129, + 0x109A, + ], + "0,10.60,0.00,10.57", + ), + ( + { + CONF_COUNT: 4, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">2i", + CONF_NAN_VALUE: 0x0000000F, + }, + # int: nan, 10, + [ + 0x0000, + 0x000F, + 0x0000, + 0x000A, + ], + "0,10", ), ( { @@ -1012,6 +1113,18 @@ async def test_lazy_error_sensor( [0x0101], "257", ), + ( + { + CONF_COUNT: 8, + CONF_PRECISION: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">4f", + }, + # floats: 7.931250095367432, 10.600000381469727, + # 1.000879611487865e-28, 10.566553115844727 + [0x40FD, 0xCCCD, 0x4129, 0x999A, 0x10FD, 0xC0CD, 0x4129, 0x109A], + "7.93,10.60,0.00,10.57", + ), ], ) async def test_struct_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: From d4952089955ddb707ae9130df36ab7a3161cf6da Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 16:19:26 +0200 Subject: [PATCH 391/640] Remove unnecessary block use of pylint disable in onvif (#100194) --- homeassistant/components/onvif/event.py | 3 +- homeassistant/components/onvif/parsers.py | 206 +++++++++++----------- 2 files changed, 100 insertions(+), 109 deletions(-) diff --git a/homeassistant/components/onvif/event.py b/homeassistant/components/onvif/event.py index bb42e63c52e759..603957a230e2ac 100644 --- a/homeassistant/components/onvif/event.py +++ b/homeassistant/components/onvif/event.py @@ -142,7 +142,6 @@ def async_callback_listeners(self) -> None: for update_callback in self._listeners: update_callback() - # pylint: disable=protected-access async def async_parse_messages(self, messages) -> None: """Parse notification message.""" unique_id = self.unique_id @@ -160,7 +159,7 @@ async def async_parse_messages(self, messages) -> None: # # Our parser expects the topic to be # tns1:RuleEngine/CellMotionDetector/Motion - topic = msg.Topic._value_1.rstrip("/.") + topic = msg.Topic._value_1.rstrip("/.") # pylint: disable=protected-access if not (parser := PARSERS.get(topic)): if topic not in UNHANDLED_TOPICS: diff --git a/homeassistant/components/onvif/parsers.py b/homeassistant/components/onvif/parsers.py index 8e6e3e25861c86..3f405767c54947 100644 --- a/homeassistant/components/onvif/parsers.py +++ b/homeassistant/components/onvif/parsers.py @@ -42,21 +42,21 @@ def local_datetime_or_none(value: str) -> datetime.datetime | None: @PARSERS.register("tns1:VideoSource/MotionAlarm") -# pylint: disable=protected-access async def async_parse_motion_alarm(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/MotionAlarm """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Motion Alarm", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @@ -65,21 +65,21 @@ async def async_parse_motion_alarm(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBlurry/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Blurry", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -89,21 +89,21 @@ async def async_parse_image_too_blurry(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_dark(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooDark/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Dark", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -113,21 +113,21 @@ async def async_parse_image_too_dark(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService") @PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService") @PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService") -# pylint: disable=protected-access async def async_parse_image_too_bright(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/ImageTooBright/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Image Too Bright", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -137,28 +137,27 @@ async def async_parse_image_too_bright(uid: str, msg) -> Event | None: @PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService") @PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService") -# pylint: disable=protected-access async def async_parse_scene_change(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:VideoSource/GlobalSceneChange/* """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Global Scene Change", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound") -# pylint: disable=protected-access async def async_parse_detected_sound(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -168,7 +167,8 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: audio_source = "" audio_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "AudioSourceConfigurationToken": audio_source = source.Value if source.Name == "AudioAnalyticsConfigurationToken": @@ -177,19 +177,18 @@ async def async_parse_detected_sound(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{audio_source}_{audio_analytics}_{rule}", + f"{uid}_{value_1}_{audio_source}_{audio_analytics}_{rule}", "Detected Sound", "binary_sensor", "sound", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside") -# pylint: disable=protected-access async def async_parse_field_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -199,7 +198,8 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -208,12 +208,12 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: rule = source.Value evt = Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Field Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) return evt except (AttributeError, KeyError): @@ -221,7 +221,6 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion") -# pylint: disable=protected-access async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -231,7 +230,8 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -240,19 +240,18 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Cell Motion Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion") -# pylint: disable=protected-access async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -262,7 +261,8 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -271,19 +271,18 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Motion Region Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value in ["1", "true"], + value_1.Data.SimpleItem[0].Value in ["1", "true"], ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper") -# pylint: disable=protected-access async def async_parse_tamper_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -293,7 +292,8 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -302,12 +302,12 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Tamper Detection", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -315,7 +315,6 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect") -# pylint: disable=protected-access async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -323,24 +322,24 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Pet Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect") -# pylint: disable=protected-access async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -348,24 +347,24 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Vehicle Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect") -# pylint: disable=protected-access async def async_parse_person_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -373,24 +372,24 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Person Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect") -# pylint: disable=protected-access async def async_parse_face_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -398,24 +397,24 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Face Detection", "binary_sensor", "motion", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor") -# pylint: disable=protected-access async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -423,80 +422,81 @@ async def async_parse_visitor_detector(uid: str, msg) -> Event | None: """ try: video_source = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "Source": video_source = _normalize_video_source(source.Value) return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}", + f"{uid}_{value_1}_{video_source}", "Visitor Detection", "binary_sensor", "occupancy", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/DigitalInput") -# pylint: disable=protected-access async def async_parse_digital_input(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/DigitalInput """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Digital Input", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/Trigger/Relay") -# pylint: disable=protected-access async def async_parse_relay(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/Trigger/Relay """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Relay Triggered", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "active", + value_1.Data.SimpleItem[0].Value == "active", ) except (AttributeError, KeyError): return None @PARSERS.register("tns1:Device/HardwareFailure/StorageFailure") -# pylint: disable=protected-access async def async_parse_storage_failure(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Device/HardwareFailure/StorageFailure """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Storage Failure", "binary_sensor", "problem", None, - msg.Message._value_1.Data.SimpleItem[0].Value == "true", + value_1.Data.SimpleItem[0].Value == "true", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -504,19 +504,19 @@ async def async_parse_storage_failure(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/ProcessorUsage") -# pylint: disable=protected-access async def async_parse_processor_usage(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/ProcessorUsage """ try: - usage = float(msg.Message._value_1.Data.SimpleItem[0].Value) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + usage = float(value_1.Data.SimpleItem[0].Value) if usage <= 1: usage *= 100 return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Processor Usage", "sensor", None, @@ -529,18 +529,16 @@ async def async_parse_processor_usage(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot") -# pylint: disable=protected-access async def async_parse_last_reboot(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReboot """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reboot", "sensor", "timestamp", @@ -553,18 +551,16 @@ async def async_parse_last_reboot(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastReset") -# pylint: disable=protected-access async def async_parse_last_reset(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastReset """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Reset", "sensor", "timestamp", @@ -578,7 +574,6 @@ async def async_parse_last_reset(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/Backup/Last") -# pylint: disable=protected-access async def async_parse_backup_last(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -586,11 +581,10 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Backup", "sensor", "timestamp", @@ -604,18 +598,16 @@ async def async_parse_backup_last(uid: str, msg) -> Event | None: @PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization") -# pylint: disable=protected-access async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: """Handle parsing event message. Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization """ try: - date_time = local_datetime_or_none( - msg.Message._value_1.Data.SimpleItem[0].Value - ) + value_1 = msg.Message._value_1 # pylint: disable=protected-access + date_time = local_datetime_or_none(value_1.Data.SimpleItem[0].Value) return Event( - f"{uid}_{msg.Topic._value_1}", + f"{uid}_{value_1}", "Last Clock Synchronization", "sensor", "timestamp", @@ -629,7 +621,6 @@ async def async_parse_last_clock_sync(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RecordingConfig/JobState") -# pylint: disable=protected-access async def async_parse_jobstate(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -637,14 +628,15 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: """ try: - source = msg.Message._value_1.Source.SimpleItem[0].Value + value_1 = msg.Message._value_1 # pylint: disable=protected-access + source = value_1.Source.SimpleItem[0].Value return Event( - f"{uid}_{msg.Topic._value_1}_{source}", + f"{uid}_{value_1}_{source}", "Recording Job State", "binary_sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value == "Active", + value_1.Data.SimpleItem[0].Value == "Active", EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -652,7 +644,6 @@ async def async_parse_jobstate(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/LineDetector/Crossed") -# pylint: disable=protected-access async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -662,7 +653,8 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = source.Value if source.Name == "VideoAnalyticsConfigurationToken": @@ -671,12 +663,12 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Line Detector Crossed", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): @@ -684,7 +676,6 @@ async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None: @PARSERS.register("tns1:RuleEngine/CountAggregation/Counter") -# pylint: disable=protected-access async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: """Handle parsing event message. @@ -694,7 +685,8 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: video_source = "" video_analytics = "" rule = "" - for source in msg.Message._value_1.Source.SimpleItem: + value_1 = msg.Message._value_1 # pylint: disable=protected-access + for source in value_1.Source.SimpleItem: if source.Name == "VideoSourceConfigurationToken": video_source = _normalize_video_source(source.Value) if source.Name == "VideoAnalyticsConfigurationToken": @@ -703,12 +695,12 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None: rule = source.Value return Event( - f"{uid}_{msg.Topic._value_1}_{video_source}_{video_analytics}_{rule}", + f"{uid}_{value_1}_{video_source}_{video_analytics}_{rule}", "Count Aggregation Counter", "sensor", None, None, - msg.Message._value_1.Data.SimpleItem[0].Value, + value_1.Data.SimpleItem[0].Value, EntityCategory.DIAGNOSTIC, ) except (AttributeError, KeyError): From 4e17901fefa82f683accdf4f27c96ea6a995cf44 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 16:37:35 +0200 Subject: [PATCH 392/640] Use shorthand attribute in Bloomsky (#100203) --- homeassistant/components/bloomsky/sensor.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/bloomsky/sensor.py b/homeassistant/components/bloomsky/sensor.py index 35c9a40a46a0b6..4361af9ad37c7d 100644 --- a/homeassistant/components/bloomsky/sensor.py +++ b/homeassistant/components/bloomsky/sensor.py @@ -100,6 +100,7 @@ def __init__(self, bs, device, sensor_name): self._sensor_name = sensor_name self._attr_name = f"{device['DeviceName']} {sensor_name}" self._attr_unique_id = f"{self._device_id}-{sensor_name}" + self._attr_device_class = SENSOR_DEVICE_CLASS.get(sensor_name) self._attr_native_unit_of_measurement = SENSOR_UNITS_IMPERIAL.get( sensor_name, None ) @@ -108,11 +109,6 @@ def __init__(self, bs, device, sensor_name): sensor_name, None ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from component DEVICE_CLASSES.""" - return SENSOR_DEVICE_CLASS.get(self._sensor_name) - def update(self) -> None: """Request an update from the BloomSky API.""" self._bloomsky.refresh_devices() From 085a584d98c5032dcb2adcf9507f1bb20233975d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:04:35 -0500 Subject: [PATCH 393/640] Use shorthand attributes in geniushub sensor (#100208) --- homeassistant/components/geniushub/sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/geniushub/sensor.py b/homeassistant/components/geniushub/sensor.py index 06237b6e8d5691..22d95be079ee31 100644 --- a/homeassistant/components/geniushub/sensor.py +++ b/homeassistant/components/geniushub/sensor.py @@ -47,6 +47,9 @@ async def async_setup_platform( class GeniusBattery(GeniusDevice, SensorEntity): """Representation of a Genius Hub sensor.""" + _attr_device_class = SensorDeviceClass.BATTERY + _attr_native_unit_of_measurement = PERCENTAGE + def __init__(self, broker, device, state_attr) -> None: """Initialize the sensor.""" super().__init__(broker, device) @@ -80,16 +83,6 @@ def icon(self) -> str: return icon - @property - def device_class(self) -> SensorDeviceClass: - """Return the device class of the sensor.""" - return SensorDeviceClass.BATTERY - - @property - def native_unit_of_measurement(self) -> str: - """Return the unit of measurement of the sensor.""" - return PERCENTAGE - @property def native_value(self) -> str: """Return the state of the sensor.""" From e2f7b3c6f8a928f61b4611f3d19359f64e65c5f0 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:05:15 -0500 Subject: [PATCH 394/640] Use shorthand attributes in buienradar camera (#100205) --- homeassistant/components/buienradar/camera.py | 22 ++++--------------- 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/buienradar/camera.py b/homeassistant/components/buienradar/camera.py index 86e650aefed7d6..439921928d65e1 100644 --- a/homeassistant/components/buienradar/camera.py +++ b/homeassistant/components/buienradar/camera.py @@ -58,6 +58,9 @@ class BuienradarCam(Camera): [0]: https://www.buienradar.nl/overbuienradar/gratis-weerdata """ + _attr_entity_registry_enabled_default = False + _attr_name = "Buienradar" + def __init__( self, latitude: float, longitude: float, delta: float, country: str ) -> None: @@ -67,8 +70,6 @@ def __init__( """ super().__init__() - self._name = "Buienradar" - # dimension (x and y) of returned radar image self._dimension = DEFAULT_DIMENSION @@ -94,12 +95,7 @@ def __init__( # deadline for image refresh - self.delta after last successful load self._deadline: datetime | None = None - self._unique_id = f"{latitude:2.6f}{longitude:2.6f}" - - @property - def name(self) -> str: - """Return the component name.""" - return self._name + self._attr_unique_id = f"{latitude:2.6f}{longitude:2.6f}" def __needs_refresh(self) -> bool: if not (self._delta and self._deadline and self._last_image): @@ -187,13 +183,3 @@ async def async_camera_image( async with self._condition: self._loading = False self._condition.notify_all() - - @property - def unique_id(self): - """Return the unique id.""" - return self._unique_id - - @property - def entity_registry_enabled_default(self) -> bool: - """Disable entity by default.""" - return False From 83ef5450e9d11dd299e767351147e0b5c620445f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 10:05:31 -0500 Subject: [PATCH 395/640] Use shorthand attributes in garadget cover (#100207) --- homeassistant/components/garadget/cover.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/garadget/cover.py b/homeassistant/components/garadget/cover.py index 826f21e9f8886a..6d9705cee756ec 100644 --- a/homeassistant/components/garadget/cover.py +++ b/homeassistant/components/garadget/cover.py @@ -92,6 +92,8 @@ def setup_platform( class GaradgetCover(CoverEntity): """Representation of a Garadget cover.""" + _attr_device_class = CoverDeviceClass.GARAGE + def __init__(self, hass, args): """Initialize the cover.""" self.particle_url = "https://api.particle.io" @@ -174,11 +176,6 @@ def is_closed(self) -> bool | None: return None return self._state == STATE_CLOSED - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE - def get_token(self): """Get new token for usage during this session.""" args = { From 6e6680dc4daa89e752d6e8b88cf6a59e6924d582 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 17:12:22 +0200 Subject: [PATCH 396/640] Enable asyncio debug mode in tests (#100197) --- tests/conftest.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 99db088449670c..f743a2fe96a4a7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -318,6 +318,12 @@ def long_repr_strings() -> Generator[None, None, None]: arepr.maxother = original_maxother +@pytest.fixture(autouse=True) +def enable_event_loop_debug(event_loop: asyncio.AbstractEventLoop) -> None: + """Enable event loop debug mode.""" + event_loop.set_debug(True) + + @pytest.fixture(autouse=True) def verify_cleanup( event_loop: asyncio.AbstractEventLoop, From 54c034185f129ce375148bf2a02ef9cefdf1c016 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:13:13 +0200 Subject: [PATCH 397/640] Use shorthand attributes in Isy994 (#100209) --- homeassistant/components/isy994/binary_sensor.py | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/isy994/binary_sensor.py b/homeassistant/components/isy994/binary_sensor.py index 27f1887bd92a47..7be3b87a0d396f 100644 --- a/homeassistant/components/isy994/binary_sensor.py +++ b/homeassistant/components/isy994/binary_sensor.py @@ -249,7 +249,8 @@ def __init__( ) -> None: """Initialize the ISY binary sensor device.""" super().__init__(node, device_info=device_info) - self._device_class = force_device_class + # This was discovered by parsing the device type code during init + self._attr_device_class = force_device_class @property def is_on(self) -> bool | None: @@ -258,14 +259,6 @@ def is_on(self) -> bool | None: return None return bool(self._node.status) - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device. - - This was discovered by parsing the device type code during init - """ - return self._device_class - class ISYInsteonBinarySensorEntity(ISYBinarySensorEntity): """Representation of an ISY Insteon binary sensor device. From 75951dd67be7c2e149348c6171a9af5b07d4de8a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:15:36 +0200 Subject: [PATCH 398/640] Use shorthand attributes in Point (#100214) --- homeassistant/components/point/__init__.py | 52 +++++-------------- .../components/point/binary_sensor.py | 18 ++----- homeassistant/components/point/sensor.py | 13 ++--- 3 files changed, 22 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/point/__init__.py b/homeassistant/components/point/__init__.py index 2030483d9cdb56..130ea116cc1004 100644 --- a/homeassistant/components/point/__init__.py +++ b/homeassistant/components/point/__init__.py @@ -264,9 +264,20 @@ def __init__(self, point_client, device_id, device_class): self._client = point_client self._id = device_id self._name = self.device.name - self._device_class = device_class + self._attr_device_class = device_class self._updated = utc_from_timestamp(0) - self._value = None + self._attr_unique_id = f"point.{device_id}-{device_class}" + device = self.device.device + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, + identifiers={(DOMAIN, device["device_id"])}, + manufacturer="Minut", + model=f"Point v{device['hardware_version']}", + name=device["description"], + sw_version=device["firmware"]["installed"], + via_device=(DOMAIN, device["home"]), + ) + self._attr_name = f"{self._name} {device_class.capitalize()}" def __str__(self): """Return string representation of device.""" @@ -298,11 +309,6 @@ def device(self): """Return the representation of the device.""" return self._client.device(self.device_id) - @property - def device_class(self): - """Return the device class.""" - return self._device_class - @property def device_id(self): """Return the id of the device.""" @@ -317,25 +323,6 @@ def extra_state_attributes(self): ) return attrs - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - device = self.device.device - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, device["device_mac"])}, - identifiers={(DOMAIN, device["device_id"])}, - manufacturer="Minut", - model=f"Point v{device['hardware_version']}", - name=device["description"], - sw_version=device["firmware"]["installed"], - via_device=(DOMAIN, device["home"]), - ) - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self.device_class.capitalize()}" - @property def is_updated(self): """Return true if sensor have been updated.""" @@ -344,15 +331,4 @@ def is_updated(self): @property def last_update(self): """Return the last_update time for the device.""" - last_update = parse_datetime(self.device.last_update) - return last_update - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self.device_class}" - - @property - def value(self): - """Return the sensor value.""" - return self._value + return parse_datetime(self.device.last_update) diff --git a/homeassistant/components/point/binary_sensor.py b/homeassistant/components/point/binary_sensor.py index e8db51fd0fc721..81101d2da797e6 100644 --- a/homeassistant/components/point/binary_sensor.py +++ b/homeassistant/components/point/binary_sensor.py @@ -76,6 +76,9 @@ def __init__(self, point_client, device_id, device_name): self._device_name = device_name self._async_unsub_hook_dispatcher_connect = None self._events = EVENTS[device_name] + self._attr_unique_id = f"point.{device_id}-{device_name}" + self._attr_icon = DEVICES[self._device_name].get("icon") + self._attr_name = f"{self._name} {device_name.capitalize()}" async def async_added_to_hass(self) -> None: """Call when entity is added to HOme Assistant.""" @@ -124,18 +127,3 @@ def _webhook_event(self, data, webhook): else: self._attr_is_on = _is_on self.async_write_ha_state() - - @property - def name(self): - """Return the display name of this device.""" - return f"{self._name} {self._device_name.capitalize()}" - - @property - def icon(self): - """Return the icon to use in the frontend, if any.""" - return DEVICES[self._device_name].get("icon") - - @property - def unique_id(self): - """Return the unique id of the sensor.""" - return f"point.{self._id}-{self._device_name}" diff --git a/homeassistant/components/point/sensor.py b/homeassistant/components/point/sensor.py index 34571c801a62e4..462d8270f0a47c 100644 --- a/homeassistant/components/point/sensor.py +++ b/homeassistant/components/point/sensor.py @@ -98,13 +98,10 @@ async def _update_callback(self): """Update the value of the sensor.""" _LOGGER.debug("Update sensor value for %s", self) if self.is_updated: - self._value = await self.device.sensor(self.device_class) + self._attr_native_value = await self.device.sensor(self.device_class) + if self.native_value is not None: + self._attr_native_value = round( + self.native_value, self.entity_description.precision + ) self._updated = parse_datetime(self.device.last_update) self.async_write_ha_state() - - @property - def native_value(self): - """Return the state of the sensor.""" - if self.value is None: - return None - return round(self.value, self.entity_description.precision) From 6485320bc44ea7a4d819b4a31bc2ed8df572dacf Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 17:31:25 +0200 Subject: [PATCH 399/640] Improve type annotations in websocket_api tests (#100198) --- .../components/websocket_api/test_commands.py | 130 +++++++++++++----- 1 file changed, 95 insertions(+), 35 deletions(-) diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 8cd5e23ce29ded..f200c44acca635 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -92,7 +92,9 @@ def _apply_entities_changes(state_dict: dict, change_dict: dict) -> None: del state_dict[STATE_KEY_LONG_NAMES[key]][item] -async def test_fire_event(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -121,7 +123,9 @@ async def event_handler(event): assert runs[0].data == {"hello": "world"} -async def test_fire_event_without_data(hass: HomeAssistant, websocket_client) -> None: +async def test_fire_event_without_data( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test fire event command.""" runs = [] @@ -149,7 +153,9 @@ async def event_handler(event): assert runs[0].data == {} -async def test_call_service(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -179,7 +185,7 @@ async def test_call_service(hass: HomeAssistant, websocket_client) -> None: @pytest.mark.parametrize("command", ("call_service", "call_service_action")) async def test_call_service_blocking( - hass: HomeAssistant, websocket_client, command + hass: HomeAssistant, websocket_client: MockHAClientWebSocket, command ) -> None: """Test call service commands block, except for homeassistant restart / stop.""" with patch( @@ -256,7 +262,9 @@ async def test_call_service_blocking( ) -async def test_call_service_target(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_target( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with target.""" calls = async_mock_service(hass, "domain_test", "test_service") @@ -316,7 +324,9 @@ async def test_call_service_target_template( assert msg["error"]["code"] == const.ERR_INVALID_FORMAT -async def test_call_service_not_found(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_not_found( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command.""" await websocket_client.send_json( { @@ -433,7 +443,9 @@ def service_call(call): assert len(calls) == 0 -async def test_call_service_error(hass: HomeAssistant, websocket_client) -> None: +async def test_call_service_error( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test call service command with error.""" @callback @@ -526,7 +538,9 @@ async def test_subscribe_unsubscribe_events( assert sum(hass.bus.async_listeners().values()) == init_count -async def test_get_states(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bye", "universe") @@ -545,7 +559,9 @@ async def test_get_states(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == states -async def test_get_services(hass: HomeAssistant, websocket_client) -> None: +async def test_get_services( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_services command.""" for id_ in (5, 6): await websocket_client.send_json({"id": id_, "type": "get_services"}) @@ -557,7 +573,9 @@ async def test_get_services(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.services.async_services() -async def test_get_config(hass: HomeAssistant, websocket_client) -> None: +async def test_get_config( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_config command.""" await websocket_client.send_json({"id": 5, "type": "get_config"}) @@ -584,7 +602,7 @@ async def test_get_config(hass: HomeAssistant, websocket_client) -> None: assert msg["result"] == hass.config.as_dict() -async def test_ping(websocket_client) -> None: +async def test_ping(websocket_client: MockHAClientWebSocket) -> None: """Test get_panels command.""" await websocket_client.send_json({"id": 5, "type": "ping"}) @@ -637,7 +655,7 @@ async def test_call_service_context_with_user( async def test_subscribe_requires_admin( - websocket_client, hass_admin_user: MockUser + websocket_client: MockHAClientWebSocket, hass_admin_user: MockUser ) -> None: """Test subscribing events without being admin.""" hass_admin_user.groups = [] @@ -668,7 +686,9 @@ async def test_states_filters_visible( assert msg["result"][0]["entity_id"] == "test.entity" -async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) -> None: +async def test_get_states_not_allows_nan( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test get_states command converts NaN to None.""" hass.states.async_set("greeting.hello", "world") hass.states.async_set("greeting.bad", "data", {"hello": float("NaN")}) @@ -691,7 +711,9 @@ async def test_get_states_not_allows_nan(hass: HomeAssistant, websocket_client) async def test_subscribe_unsubscribe_events_whitelist( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe events on whitelist.""" hass_admin_user.groups = [] @@ -728,7 +750,9 @@ async def test_subscribe_unsubscribe_events_whitelist( async def test_subscribe_unsubscribe_events_state_changed( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe state_changed events.""" hass_admin_user.groups = [] @@ -754,7 +778,9 @@ async def test_subscribe_unsubscribe_events_state_changed( async def test_subscribe_entities_with_unserializable_state( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe entities with an unserializeable state.""" @@ -871,7 +897,9 @@ def __init__(self): async def test_subscribe_unsubscribe_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities.""" @@ -1037,7 +1065,9 @@ async def test_subscribe_unsubscribe_entities( async def test_subscribe_unsubscribe_entities_specific_entities( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe entities with a list of entity ids.""" @@ -1376,7 +1406,7 @@ async def test_render_template_with_error( ) async def test_render_template_with_timeout_and_error( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events: list[dict[str, str]], @@ -1592,7 +1622,7 @@ async def test_render_template_strict_with_timeout_and_error_2( ) async def test_render_template_error_in_template_code( hass: HomeAssistant, - websocket_client, + websocket_client: MockHAClientWebSocket, caplog: pytest.LogCaptureFixture, template: str, expected_events_1: list[dict[str, str]], @@ -1691,7 +1721,9 @@ async def test_render_template_error_in_template_code_2( async def test_render_template_with_delayed_error( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template with an error that only happens after a state change. @@ -1815,7 +1847,9 @@ async def test_render_template_with_delayed_error_2( async def test_render_template_with_timeout( - hass: HomeAssistant, websocket_client, caplog: pytest.LogCaptureFixture + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + caplog: pytest.LogCaptureFixture, ) -> None: """Test a template that will timeout.""" @@ -1859,7 +1893,9 @@ async def test_render_template_returns_with_match_all( assert msg["success"] -async def test_manifest_list(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_list( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test loading manifests.""" http = await async_get_integration(hass, "http") websocket_api = await async_get_integration(hass, "websocket_api") @@ -1897,7 +1933,9 @@ async def test_manifest_list_specific_integrations( ] -async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: +async def test_manifest_get( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test getting a manifest.""" hue = await async_get_integration(hass, "hue") @@ -1924,7 +1962,9 @@ async def test_manifest_get(hass: HomeAssistant, websocket_client) -> None: async def test_entity_source_admin( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Check that we fetch sources correctly.""" platform = MockEntityPlatform(hass) @@ -1963,7 +2003,9 @@ async def test_entity_source_admin( } -async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: +async def test_subscribe_trigger( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test subscribing to a trigger.""" init_count = sum(hass.bus.async_listeners().values()) @@ -2017,7 +2059,9 @@ async def test_subscribe_trigger(hass: HomeAssistant, websocket_client) -> None: assert sum(hass.bus.async_listeners().values()) == init_count -async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: +async def test_test_condition( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" hass.states.async_set("hello.world", "paulus") @@ -2077,7 +2121,9 @@ async def test_test_condition(hass: HomeAssistant, websocket_client) -> None: assert msg["result"]["result"] is False -async def test_execute_script(hass: HomeAssistant, websocket_client) -> None: +async def test_execute_script( + hass: HomeAssistant, websocket_client: MockHAClientWebSocket +) -> None: """Test testing a condition.""" calls = async_mock_service( hass, "domain_test", "test_service", response={"hello": "world"} @@ -2226,7 +2272,9 @@ async def test_execute_script_with_dynamically_validated_action( async def test_subscribe_unsubscribe_bootstrap_integrations( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" await websocket_client.send_json( @@ -2248,7 +2296,9 @@ async def test_subscribe_unsubscribe_bootstrap_integrations( async def test_integration_setup_info( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test subscribe/unsubscribe bootstrap_integrations.""" hass.data[DATA_SETUP_TIME] = { @@ -2284,7 +2334,9 @@ async def test_integration_setup_info( ("action", [{"service": "domain_test.test_service"}]), ), ) -async def test_validate_config_works(websocket_client, key, config) -> None: +async def test_validate_config_works( + websocket_client: MockHAClientWebSocket, key, config +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -2323,7 +2375,9 @@ async def test_validate_config_works(websocket_client, key, config) -> None: ), ), ) -async def test_validate_config_invalid(websocket_client, key, config, error) -> None: +async def test_validate_config_invalid( + websocket_client: MockHAClientWebSocket, key, config, error +) -> None: """Test config validation.""" await websocket_client.send_json({"id": 7, "type": "validate_config", key: config}) @@ -2335,7 +2389,9 @@ async def test_validate_config_invalid(websocket_client, key, config, error) -> async def test_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing.""" await websocket_client.send_json( @@ -2407,7 +2463,9 @@ async def test_message_coalescing( async def test_message_coalescing_not_supported_by_websocket_client( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test enabling message coalescing not supported by websocket client.""" await websocket_client.send_json({"id": 7, "type": "subscribe_entities"}) @@ -2449,7 +2507,9 @@ async def test_message_coalescing_not_supported_by_websocket_client( async def test_client_message_coalescing( - hass: HomeAssistant, websocket_client, hass_admin_user: MockUser + hass: HomeAssistant, + websocket_client: MockHAClientWebSocket, + hass_admin_user: MockUser, ) -> None: """Test client message coalescing.""" await websocket_client.send_json( From 6545fba5499230918dfe6e12c003cc7f082ace49 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:34:41 +0200 Subject: [PATCH 400/640] Use shorthand attributes in Universal (#100219) --- homeassistant/components/universal/media_player.py | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/universal/media_player.py b/homeassistant/components/universal/media_player.py index c221a10284a9fc..00f345fd248e83 100644 --- a/homeassistant/components/universal/media_player.py +++ b/homeassistant/components/universal/media_player.py @@ -40,7 +40,6 @@ SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOUND_MODE, SERVICE_SELECT_SOURCE, - MediaPlayerDeviceClass, MediaPlayerEntity, MediaPlayerEntityFeature, MediaPlayerState, @@ -177,7 +176,7 @@ def __init__( self._child_state = None self._state_template_result = None self._state_template = config.get(CONF_STATE_TEMPLATE) - self._device_class = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._browse_media_entity = config.get(CONF_BROWSE_MEDIA_ENTITY) @@ -294,11 +293,6 @@ async def _async_call_service( DOMAIN, service_name, service_data, blocking=True, context=self._context ) - @property - def device_class(self) -> MediaPlayerDeviceClass | None: - """Return the class of this device.""" - return self._device_class - @property def master_state(self): """Return the master state for entity or None.""" From 4e202eb3767622bdfd2995955c25cf6c3edfc72b Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 17:35:01 +0200 Subject: [PATCH 401/640] Use shorthand attributes in Yamaha Musiccast (#100220) --- .../components/yamaha_musiccast/__init__.py | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/__init__.py b/homeassistant/components/yamaha_musiccast/__init__.py index c3851074365881..9e8b8fed530f32 100644 --- a/homeassistant/components/yamaha_musiccast/__init__.py +++ b/homeassistant/components/yamaha_musiccast/__init__.py @@ -136,24 +136,9 @@ def __init__( ) -> None: """Initialize the MusicCast entity.""" super().__init__(coordinator) - self._enabled_default = enabled_default - self._icon = icon - self._name = name - - @property - def name(self) -> str: - """Return the name of the entity.""" - return self._name - - @property - def icon(self) -> str: - """Return the mdi icon of the entity.""" - return self._icon - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default + self._attr_entity_registry_enabled_default = enabled_default + self._attr_icon = icon + self._attr_name = name class MusicCastDeviceEntity(MusicCastEntity): From 71c4f675e0eb15944fa00b05495b157a6a2cf1aa Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 18:01:05 +0200 Subject: [PATCH 402/640] Use shorthand attributes in SPC (#100217) --- homeassistant/components/spc/alarm_control_panel.py | 6 +----- homeassistant/components/spc/binary_sensor.py | 12 ++---------- 2 files changed, 3 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/spc/alarm_control_panel.py b/homeassistant/components/spc/alarm_control_panel.py index b78703666bc304..ace352b2ba06c0 100644 --- a/homeassistant/components/spc/alarm_control_panel.py +++ b/homeassistant/components/spc/alarm_control_panel.py @@ -64,6 +64,7 @@ def __init__(self, area: Area, api: SpcWebGateway) -> None: """Initialize the SPC alarm panel.""" self._area = area self._api = api + self._attr_name = area.name async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -80,11 +81,6 @@ def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._area.name - @property def changed_by(self) -> str: """Return the user the last change was triggered by.""" diff --git a/homeassistant/components/spc/binary_sensor.py b/homeassistant/components/spc/binary_sensor.py index c4aaefdd5180b6..a43551567e6fb8 100644 --- a/homeassistant/components/spc/binary_sensor.py +++ b/homeassistant/components/spc/binary_sensor.py @@ -53,6 +53,8 @@ class SpcBinarySensor(BinarySensorEntity): def __init__(self, zone: Zone) -> None: """Initialize the sensor device.""" self._zone = zone + self._attr_name = zone.name + self._attr_device_class = _get_device_class(zone.type) async def async_added_to_hass(self) -> None: """Call for adding new entities.""" @@ -69,17 +71,7 @@ def _update_callback(self) -> None: """Call update method.""" self.async_schedule_update_ha_state(True) - @property - def name(self) -> str: - """Return the name of the device.""" - return self._zone.name - @property def is_on(self) -> bool: """Whether the device is switched on.""" return self._zone.input == ZoneInput.OPEN - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the device class.""" - return _get_device_class(self._zone.type) From 86bccf769ec1052c7ae169c33ed4f0ef35a5c2e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Klomp?= Date: Tue, 12 Sep 2023 18:30:55 +0200 Subject: [PATCH 403/640] Add Entity Descriptions to SMA integration (#58707) Co-authored-by: J. Nick Koston --- homeassistant/components/sma/sensor.py | 811 ++++++++++++++++++++++++- 1 file changed, 782 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index dbcc1931e58327..11ed720b51c1ec 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -8,10 +8,22 @@ from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import UnitOfEnergy, UnitOfPower +from homeassistant.const import ( + PERCENTAGE, + POWER_VOLT_AMPERE_REACTIVE, + EntityCategory, + UnitOfApparentPower, + UnitOfElectricCurrent, + UnitOfElectricPotential, + UnitOfEnergy, + UnitOfFrequency, + UnitOfPower, + UnitOfTemperature, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -23,6 +35,762 @@ from .const import DOMAIN, PYSMA_COORDINATOR, PYSMA_DEVICE_INFO, PYSMA_SENSORS +SENSOR_ENTITIES: dict[str, SensorEntityDescription] = { + "status": SensorEntityDescription( + key="status", + name="Status", + entity_category=EntityCategory.DIAGNOSTIC, + ), + "operating_status_general": SensorEntityDescription( + key="operating_status_general", + name="Operating Status General", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_condition": SensorEntityDescription( + key="inverter_condition", + name="Inverter Condition", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "inverter_system_init": SensorEntityDescription( + key="inverter_system_init", + name="Inverter System Init", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_connection_status": SensorEntityDescription( + key="grid_connection_status", + name="Grid Connection Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "grid_relay_status": SensorEntityDescription( + key="grid_relay_status", + name="Grid Relay Status", + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + ), + "pv_power_a": SensorEntityDescription( + key="pv_power_a", + name="PV Power A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_b": SensorEntityDescription( + key="pv_power_b", + name="PV Power B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "pv_power_c": SensorEntityDescription( + key="pv_power_c", + name="PV Power C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "pv_voltage_a": SensorEntityDescription( + key="pv_voltage_a", + name="PV Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_b": SensorEntityDescription( + key="pv_voltage_b", + name="PV Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_voltage_c": SensorEntityDescription( + key="pv_voltage_c", + name="PV Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "pv_current_a": SensorEntityDescription( + key="pv_current_a", + name="PV Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_b": SensorEntityDescription( + key="pv_current_b", + name="PV Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "pv_current_c": SensorEntityDescription( + key="pv_current_c", + name="PV Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "insulation_residual_current": SensorEntityDescription( + key="insulation_residual_current", + name="Insulation Residual Current", + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "grid_power": SensorEntityDescription( + key="grid_power", + name="Grid Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "frequency": SensorEntityDescription( + key="frequency", + name="Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + entity_registry_enabled_default=False, + ), + "power_l1": SensorEntityDescription( + key="power_l1", + name="Power L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l2": SensorEntityDescription( + key="power_l2", + name="Power L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "power_l3": SensorEntityDescription( + key="power_l3", + name="Power L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power": SensorEntityDescription( + key="grid_reactive_power", + name="Grid Reactive Power", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l1": SensorEntityDescription( + key="grid_reactive_power_l1", + name="Grid Reactive Power L1", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l2": SensorEntityDescription( + key="grid_reactive_power_l2", + name="Grid Reactive Power L2", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_reactive_power_l3": SensorEntityDescription( + key="grid_reactive_power_l3", + name="Grid Reactive Power L3", + native_unit_of_measurement=POWER_VOLT_AMPERE_REACTIVE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.REACTIVE_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power": SensorEntityDescription( + key="grid_apparent_power", + name="Grid Apparent Power", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l1": SensorEntityDescription( + key="grid_apparent_power_l1", + name="Grid Apparent Power L1", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l2": SensorEntityDescription( + key="grid_apparent_power_l2", + name="Grid Apparent Power L2", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_apparent_power_l3": SensorEntityDescription( + key="grid_apparent_power_l3", + name="Grid Apparent Power L3", + native_unit_of_measurement=UnitOfApparentPower.VOLT_AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.APPARENT_POWER, + entity_registry_enabled_default=False, + ), + "grid_power_factor": SensorEntityDescription( + key="grid_power_factor", + name="Grid Power Factor", + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER_FACTOR, + entity_registry_enabled_default=False, + ), + "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, + ), + "current_l1": SensorEntityDescription( + key="current_l1", + name="Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l2": SensorEntityDescription( + key="current_l2", + name="Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_l3": SensorEntityDescription( + key="current_l3", + name="Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "current_total": SensorEntityDescription( + key="current_total", + name="Current Total", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "voltage_l1": SensorEntityDescription( + key="voltage_l1", + name="Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l2": SensorEntityDescription( + key="voltage_l2", + name="Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "voltage_l3": SensorEntityDescription( + key="voltage_l3", + name="Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "total_yield": SensorEntityDescription( + key="total_yield", + name="Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "daily_yield": SensorEntityDescription( + key="daily_yield", + name="Daily Yield", + native_unit_of_measurement=UnitOfEnergy.WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_power_supplied": SensorEntityDescription( + key="metering_power_supplied", + name="Metering Power Supplied", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_power_absorbed": SensorEntityDescription( + key="metering_power_absorbed", + name="Metering Power Absorbed", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_frequency": SensorEntityDescription( + key="metering_frequency", + name="Metering Frequency", + native_unit_of_measurement=UnitOfFrequency.HERTZ, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.FREQUENCY, + ), + "metering_total_yield": SensorEntityDescription( + key="metering_total_yield", + name="Metering Total Yield", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_total_absorbed": SensorEntityDescription( + key="metering_total_absorbed", + name="Metering Total Absorbed", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "metering_current_l1": SensorEntityDescription( + key="metering_current_l1", + name="Metering Current L1", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l2": SensorEntityDescription( + key="metering_current_l2", + name="Metering Current L2", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_current_l3": SensorEntityDescription( + key="metering_current_l3", + name="Metering Current L3", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "metering_voltage_l1": SensorEntityDescription( + key="metering_voltage_l1", + name="Metering Voltage L1", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l2": SensorEntityDescription( + key="metering_voltage_l2", + name="Metering Voltage L2", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_voltage_l3": SensorEntityDescription( + key="metering_voltage_l3", + name="Metering Voltage L3", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "metering_active_power_feed_l1": SensorEntityDescription( + key="metering_active_power_feed_l1", + name="Metering Active Power Feed L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l2": SensorEntityDescription( + key="metering_active_power_feed_l2", + name="Metering Active Power Feed L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_feed_l3": SensorEntityDescription( + key="metering_active_power_feed_l3", + name="Metering Active Power Feed L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l1": SensorEntityDescription( + key="metering_active_power_draw_l1", + name="Metering Active Power Draw L1", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l2": SensorEntityDescription( + key="metering_active_power_draw_l2", + name="Metering Active Power Draw L2", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_active_power_draw_l3": SensorEntityDescription( + key="metering_active_power_draw_l3", + name="Metering Active Power Draw L3", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "metering_current_consumption": SensorEntityDescription( + key="metering_current_consumption", + name="Metering Current Consumption", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "metering_total_consumption": SensorEntityDescription( + key="metering_total_consumption", + name="Metering Total Consumption", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "pv_gen_meter": SensorEntityDescription( + key="pv_gen_meter", + name="PV Gen Meter", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "optimizer_power": SensorEntityDescription( + key="optimizer_power", + name="Optimizer Power", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "optimizer_current": SensorEntityDescription( + key="optimizer_current", + name="Optimizer Current", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + entity_registry_enabled_default=False, + ), + "optimizer_voltage": SensorEntityDescription( + key="optimizer_voltage", + name="Optimizer Voltage", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "optimizer_temp": SensorEntityDescription( + key="optimizer_temp", + name="Optimizer Temp", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + entity_registry_enabled_default=False, + ), + "battery_soc_total": SensorEntityDescription( + key="battery_soc_total", + name="Battery SOC Total", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + ), + "battery_soc_a": SensorEntityDescription( + key="battery_soc_a", + name="Battery SOC A", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_b": SensorEntityDescription( + key="battery_soc_b", + name="Battery SOC B", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_soc_c": SensorEntityDescription( + key="battery_soc_c", + name="Battery SOC C", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.BATTERY, + entity_registry_enabled_default=False, + ), + "battery_voltage_a": SensorEntityDescription( + key="battery_voltage_a", + name="Battery Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_b": SensorEntityDescription( + key="battery_voltage_b", + name="Battery Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_voltage_c": SensorEntityDescription( + key="battery_voltage_c", + name="Battery Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_current_a": SensorEntityDescription( + key="battery_current_a", + name="Battery Current A", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_b": SensorEntityDescription( + key="battery_current_b", + name="Battery Current B", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_current_c": SensorEntityDescription( + key="battery_current_c", + name="Battery Current C", + native_unit_of_measurement=UnitOfElectricCurrent.AMPERE, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.CURRENT, + ), + "battery_temp_a": SensorEntityDescription( + key="battery_temp_a", + name="Battery Temp A", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_b": SensorEntityDescription( + key="battery_temp_b", + name="Battery Temp B", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_temp_c": SensorEntityDescription( + key="battery_temp_c", + name="Battery Temp C", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.TEMPERATURE, + ), + "battery_status_operating_mode": SensorEntityDescription( + key="battery_status_operating_mode", + name="Battery Status Operating Mode", + ), + "battery_capacity_total": SensorEntityDescription( + key="battery_capacity_total", + name="Battery Capacity Total", + native_unit_of_measurement=PERCENTAGE, + ), + "battery_capacity_a": SensorEntityDescription( + key="battery_capacity_a", + name="Battery Capacity A", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_b": SensorEntityDescription( + key="battery_capacity_b", + name="Battery Capacity B", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_capacity_c": SensorEntityDescription( + key="battery_capacity_c", + name="Battery Capacity C", + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_a": SensorEntityDescription( + key="battery_charging_voltage_a", + name="Battery Charging Voltage A", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_b": SensorEntityDescription( + key="battery_charging_voltage_b", + name="Battery Charging Voltage B", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_charging_voltage_c": SensorEntityDescription( + key="battery_charging_voltage_c", + name="Battery Charging Voltage C", + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + ), + "battery_power_charge_total": SensorEntityDescription( + key="battery_power_charge_total", + name="Battery Power Charge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_charge_a": SensorEntityDescription( + key="battery_power_charge_a", + name="Battery Power Charge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_b": SensorEntityDescription( + key="battery_power_charge_b", + name="Battery Power Charge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_charge_c": SensorEntityDescription( + key="battery_power_charge_c", + name="Battery Power Charge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_charge_total": SensorEntityDescription( + key="battery_charge_total", + name="Battery Charge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_charge_a": SensorEntityDescription( + key="battery_charge_a", + name="Battery Charge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_b": SensorEntityDescription( + key="battery_charge_b", + name="Battery Charge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_charge_c": SensorEntityDescription( + key="battery_charge_c", + name="Battery Charge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_total": SensorEntityDescription( + key="battery_power_discharge_total", + name="Battery Power Discharge Total", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), + "battery_power_discharge_a": SensorEntityDescription( + key="battery_power_discharge_a", + name="Battery Power Discharge A", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_b": SensorEntityDescription( + key="battery_power_discharge_b", + name="Battery Power Discharge B", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_power_discharge_c": SensorEntityDescription( + key="battery_power_discharge_c", + name="Battery Power Discharge C", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + entity_registry_enabled_default=False, + ), + "battery_discharge_total": SensorEntityDescription( + key="battery_discharge_total", + name="Battery Discharge Total", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + ), + "battery_discharge_a": SensorEntityDescription( + key="battery_discharge_a", + name="Battery Discharge A", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_b": SensorEntityDescription( + key="battery_discharge_b", + name="Battery Discharge B", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "battery_discharge_c": SensorEntityDescription( + key="battery_discharge_c", + name="Battery Discharge C", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + state_class=SensorStateClass.TOTAL_INCREASING, + device_class=SensorDeviceClass.ENERGY, + entity_registry_enabled_default=False, + ), + "inverter_power_limit": SensorEntityDescription( + key="inverter_power_limit", + name="Inverter Power Limit", + native_unit_of_measurement=UnitOfPower.WATT, + state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.POWER, + ), +} + async def async_setup_entry( hass: HomeAssistant, @@ -45,6 +813,7 @@ async def async_setup_entry( SMAsensor( coordinator, config_entry.unique_id, + SENSOR_ENTITIES.get(sensor.name), device_info, sensor, ) @@ -60,22 +829,23 @@ def __init__( self, coordinator: DataUpdateCoordinator, config_entry_unique_id: str, + description: SensorEntityDescription | None, device_info: DeviceInfo, pysma_sensor: pysma.sensor.Sensor, ) -> None: """Initialize the sensor.""" super().__init__(coordinator) + if description is not None: + self.entity_description = description + else: + self._attr_name = pysma_sensor.name + self._sensor = pysma_sensor - self._enabled_default = self._sensor.enabled - self._config_entry_unique_id = config_entry_unique_id - self._attr_device_info = device_info - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - self._attr_state_class = SensorStateClass.TOTAL_INCREASING - self._attr_device_class = SensorDeviceClass.ENERGY - if self.native_unit_of_measurement == UnitOfPower.WATT: - self._attr_state_class = SensorStateClass.MEASUREMENT - self._attr_device_class = SensorDeviceClass.POWER + self._attr_device_info = device_info + self._attr_unique_id = ( + f"{config_entry_unique_id}-{pysma_sensor.key}_{pysma_sensor.key_idx}" + ) # Set sensor enabled to False. # Will be enabled by async_added_to_hass if actually used. @@ -83,36 +853,19 @@ def __init__( @property def name(self) -> str: - """Return the name of the sensor.""" + """Return the name of the sensor prefixed with the device name.""" if self._attr_device_info is None or not ( name_prefix := self._attr_device_info.get("name") ): name_prefix = "SMA" - return f"{name_prefix} {self._sensor.name}" + return f"{name_prefix} {super().name}" @property def native_value(self) -> StateType: """Return the state of the sensor.""" return self._sensor.value - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return self._sensor.unit - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return ( - f"{self._config_entry_unique_id}-{self._sensor.key}_{self._sensor.key_idx}" - ) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self._enabled_default - async def async_added_to_hass(self) -> None: """Run when entity about to be added to hass.""" await super().async_added_to_hass() From e84a4661b0eebc5ecff9bc25ae3ee9c229fe606c Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 18:54:32 +0200 Subject: [PATCH 404/640] Add intial property to imap_content event data (#100171) * Add initial property to imap event data * Simplify loop Co-authored-by: Joost Lekkerkerker * MyPy --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/imap/coordinator.py | 48 ++++++++++++++------ tests/components/imap/const.py | 1 + tests/components/imap/test_init.py | 3 +- 3 files changed, 37 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/imap/coordinator.py b/homeassistant/components/imap/coordinator.py index 72be5e9bcf0364..59c24b11e51e1a 100644 --- a/homeassistant/components/imap/coordinator.py +++ b/homeassistant/components/imap/coordinator.py @@ -110,6 +110,15 @@ def headers(self) -> dict[str, tuple[str,]]: header_base[key] += header_instances # type: ignore[assignment] return header_base + @property + def message_id(self) -> str | None: + """Get the message ID.""" + value: str + for header, value in self.email_message.items(): + if header == "Message-ID": + return value + return None + @property def date(self) -> datetime | None: """Get the date the email was sent.""" @@ -189,6 +198,7 @@ def __init__( """Initiate imap client.""" self.imap_client = imap_client self.auth_errors: int = 0 + self._last_message_uid: str | None = None self._last_message_id: str | None = None self.custom_event_template = None _custom_event_template = entry.data.get(CONF_CUSTOM_EVENT_DATA_TEMPLATE) @@ -209,16 +219,22 @@ async def _async_reconnect_if_needed(self) -> None: if self.imap_client is None: self.imap_client = await connect_to_server(self.config_entry.data) - async def _async_process_event(self, last_message_id: str) -> None: + async def _async_process_event(self, last_message_uid: str) -> None: """Send a event for the last message if the last message was changed.""" - response = await self.imap_client.fetch(last_message_id, "BODY.PEEK[]") + response = await self.imap_client.fetch(last_message_uid, "BODY.PEEK[]") if response.result == "OK": message = ImapMessage(response.lines[1]) + # Set `initial` to `False` if the last message is triggered again + initial: bool = True + if (message_id := message.message_id) == self._last_message_id: + initial = False + self._last_message_id = message_id data = { "server": self.config_entry.data[CONF_SERVER], "username": self.config_entry.data[CONF_USERNAME], "search": self.config_entry.data[CONF_SEARCH], "folder": self.config_entry.data[CONF_FOLDER], + "initial": initial, "date": message.date, "text": message.text, "sender": message.sender, @@ -231,18 +247,20 @@ async def _async_process_event(self, last_message_id: str) -> None: data, parse_result=True ) _LOGGER.debug( - "imap custom template (%s) for msgid %s rendered to: %s", + "IMAP custom template (%s) for msguid %s (%s) rendered to: %s, initial: %s", self.custom_event_template, - last_message_id, + last_message_uid, + message_id, data["custom"], + initial, ) except TemplateError as err: data["custom"] = None _LOGGER.error( - "Error rendering imap custom template (%s) for msgid %s " + "Error rendering IMAP custom template (%s) for msguid %s " "failed with message: %s", self.custom_event_template, - last_message_id, + last_message_uid, err, ) data["text"] = message.text[ @@ -263,10 +281,12 @@ async def _async_process_event(self, last_message_id: str) -> None: self.hass.bus.fire(EVENT_IMAP, data) _LOGGER.debug( - "Message with id %s processed, sender: %s, subject: %s", - last_message_id, + "Message with id %s (%s) processed, sender: %s, subject: %s, initial: %s", + last_message_uid, + message_id, message.sender, message.subject, + initial, ) async def _async_fetch_number_of_messages(self) -> int | None: @@ -282,20 +302,20 @@ async def _async_fetch_number_of_messages(self) -> int | None: f"Invalid response for search '{self.config_entry.data[CONF_SEARCH]}': {result} / {lines[0]}" ) if not (count := len(message_ids := lines[0].split())): - self._last_message_id = None + self._last_message_uid = None return 0 - last_message_id = ( + last_message_uid = ( str(message_ids[-1:][0], encoding=self.config_entry.data[CONF_CHARSET]) if count else None ) if ( count - and last_message_id is not None - and self._last_message_id != last_message_id + and last_message_uid is not None + and self._last_message_uid != last_message_uid ): - self._last_message_id = last_message_id - await self._async_process_event(last_message_id) + self._last_message_uid = last_message_uid + await self._async_process_event(last_message_uid) return count diff --git a/tests/components/imap/const.py b/tests/components/imap/const.py index e7fca106ff76cc..ec864fd4665ab0 100644 --- a/tests/components/imap/const.py +++ b/tests/components/imap/const.py @@ -22,6 +22,7 @@ b"To: notify@example.com\r\n" b"From: John Doe \r\n" b"Subject: Test subject\r\n" + b"Message-ID: " ) TEST_MESSAGE_HEADERS3 = b"" diff --git a/tests/components/imap/test_init.py b/tests/components/imap/test_init.py index b4ee11ba787d2f..ceda841202c1a4 100644 --- a/tests/components/imap/test_init.py +++ b/tests/components/imap/test_init.py @@ -512,6 +512,7 @@ async def _sleep_till_event() -> None: assert data["sender"] == "john.doe@example.com" assert data["subject"] == "Test subject" assert data["text"] + assert data["initial"] assert ( valid_date and isinstance(data["date"], datetime) @@ -628,7 +629,7 @@ async def test_message_is_truncated( [ ("{{ subject }}", "Test subject", None), ('{{ "@example.com" in sender }}', True, None), - ("{% bad template }}", None, "Error rendering imap custom template"), + ("{% bad template }}", None, "Error rendering IMAP custom template"), ], ids=["subject_test", "sender_filter", "template_error"], ) From a09372590f92112604fce2060f2d920d249a321a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 19:26:33 +0200 Subject: [PATCH 405/640] Use shorthand attributes in Smartthings (#100215) --- .../components/smartthings/__init__.py | 34 +++------ .../components/smartthings/binary_sensor.py | 24 ++----- homeassistant/components/smartthings/cover.py | 35 +++------ homeassistant/components/smartthings/fan.py | 6 +- homeassistant/components/smartthings/light.py | 52 ++++---------- homeassistant/components/smartthings/scene.py | 12 +--- .../components/smartthings/sensor.py | 71 +++++-------------- 7 files changed, 59 insertions(+), 175 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 22856bdb05ba30..cdf04be29f303c 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -429,6 +429,17 @@ def __init__(self, device: DeviceEntity) -> None: """Initialize the instance.""" self._device = device self._dispatcher_remove = None + self._attr_name = device.label + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + configuration_url="https://account.smartthings.com", + identifiers={(DOMAIN, device.device_id)}, + manufacturer=device.status.ocf_manufacturer_name, + model=device.status.ocf_model_number, + name=device.label, + hw_version=device.status.ocf_hardware_version, + sw_version=device.status.ocf_firmware_version, + ) async def async_added_to_hass(self): """Device added to hass.""" @@ -446,26 +457,3 @@ async def async_will_remove_from_hass(self) -> None: """Disconnect the device when removed.""" if self._dispatcher_remove: self._dispatcher_remove() - - @property - def device_info(self) -> DeviceInfo: - """Get attributes about the device.""" - return DeviceInfo( - configuration_url="https://account.smartthings.com", - identifiers={(DOMAIN, self._device.device_id)}, - manufacturer=self._device.status.ocf_manufacturer_name, - model=self._device.status.ocf_model_number, - name=self._device.label, - hw_version=self._device.status.ocf_hardware_version, - sw_version=self._device.status.ocf_firmware_version, - ) - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._device.label - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._device.device_id diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index d0ffd0ac29d5f6..25f9fa224ff6f3 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -73,28 +73,12 @@ def __init__(self, device, attribute): """Init the class.""" super().__init__(device) self._attribute = attribute - - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return f"{self._device.label} {self._attribute}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" + self._attr_name = f"{device.label} {attribute}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = ATTRIB_TO_CLASS[attribute] + self._attr_entity_category = ATTRIB_TO_ENTTIY_CATEGORY.get(attribute) @property def is_on(self): """Return true if the binary sensor is on.""" return self._device.status.is_on(self._attribute) - - @property - def device_class(self): - """Return the class of this device.""" - return ATTRIB_TO_CLASS[self._attribute] - - @property - def entity_category(self): - """Return the entity category of this device.""" - return ATTRIB_TO_ENTTIY_CATEGORY.get(self._attribute) diff --git a/homeassistant/components/smartthings/cover.py b/homeassistant/components/smartthings/cover.py index 5d7e29c131215c..83522c61794e98 100644 --- a/homeassistant/components/smartthings/cover.py +++ b/homeassistant/components/smartthings/cover.py @@ -77,10 +77,8 @@ class SmartThingsCover(SmartThingsEntity, CoverEntity): def __init__(self, device): """Initialize the cover class.""" super().__init__(device) - self._device_class = None self._current_cover_position = None self._state = None - self._state_attrs = None self._attr_supported_features = ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE ) @@ -90,6 +88,13 @@ def __init__(self, device): ): self._attr_supported_features |= CoverEntityFeature.SET_POSITION + if Capability.door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.DOOR + elif Capability.window_shade in device.capabilities: + self._attr_device_class = CoverDeviceClass.SHADE + elif Capability.garage_door_control in device.capabilities: + self._attr_device_class = CoverDeviceClass.GARAGE + async def async_close_cover(self, **kwargs: Any) -> None: """Close cover.""" # Same command for all 3 supported capabilities @@ -121,24 +126,21 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: async def async_update(self) -> None: """Update the attrs of the cover.""" if Capability.door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.DOOR self._state = VALUE_TO_STATE.get(self._device.status.door) elif Capability.window_shade in self._device.capabilities: - self._device_class = CoverDeviceClass.SHADE self._state = VALUE_TO_STATE.get(self._device.status.window_shade) elif Capability.garage_door_control in self._device.capabilities: - self._device_class = CoverDeviceClass.GARAGE self._state = VALUE_TO_STATE.get(self._device.status.door) if Capability.window_shade_level in self._device.capabilities: - self._current_cover_position = self._device.status.shade_level + self._attr_current_cover_position = self._device.status.shade_level elif Capability.switch_level in self._device.capabilities: - self._current_cover_position = self._device.status.level + self._attr_current_cover_position = self._device.status.level - self._state_attrs = {} + self._attr_extra_state_attributes = {} battery = self._device.status.attributes[Attribute.battery].value if battery is not None: - self._state_attrs[ATTR_BATTERY_LEVEL] = battery + self._attr_extra_state_attributes[ATTR_BATTERY_LEVEL] = battery @property def is_opening(self) -> bool: @@ -156,18 +158,3 @@ def is_closed(self) -> bool | None: if self._state == STATE_CLOSED: return True return None if self._state is None else False - - @property - def current_cover_position(self) -> int | None: - """Return current position of cover.""" - return self._current_cover_position - - @property - def device_class(self) -> CoverDeviceClass | None: - """Define this cover as a garage door.""" - return self._device_class - - @property - def extra_state_attributes(self) -> dict[str, Any]: - """Get additional state attributes.""" - return self._state_attrs diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 7278f350dc1745..ebf80e22909c52 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -52,6 +52,7 @@ class SmartThingsFan(SmartThingsEntity, FanEntity): """Define a SmartThings Fan.""" _attr_supported_features = FanEntityFeature.SET_SPEED + _attr_speed_count = int_states_in_range(SPEED_RANGE) async def async_set_percentage(self, percentage: int) -> None: """Set the speed percentage of the fan.""" @@ -94,8 +95,3 @@ def is_on(self) -> bool: def percentage(self) -> int: """Return the current speed percentage.""" return ranged_value_to_percentage(SPEED_RANGE, self._device.status.fan_speed) - - @property - def speed_count(self) -> int: - """Return the number of speeds the fan supports.""" - return int_states_in_range(SPEED_RANGE) diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index 37237323d1ce20..58623e08394884 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -75,12 +75,19 @@ class SmartThingsLight(SmartThingsEntity, LightEntity): _attr_supported_color_modes: set[ColorMode] + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # lowest kelvin found supported across 20+ handlers. + _attr_max_mireds = 500 # 2000K + + # SmartThings does not expose this attribute, instead it's + # implemented within each device-type handler. This value is the + # highest kelvin found supported across 20+ handlers. + _attr_min_mireds = 111 # 9000K + def __init__(self, device): """Initialize a SmartThingsLight.""" super().__init__(device) - self._brightness = None - self._color_temp = None - self._hs_color = None self._attr_supported_color_modes = self._determine_color_modes() self._attr_supported_features = self._determine_features() @@ -151,17 +158,17 @@ async def async_update(self) -> None: """Update entity attributes when the device status has changed.""" # Brightness and transition if brightness_supported(self._attr_supported_color_modes): - self._brightness = int( + self._attr_brightness = int( convert_scale(self._device.status.level, 100, 255, 0) ) # Color Temperature if ColorMode.COLOR_TEMP in self._attr_supported_color_modes: - self._color_temp = color_util.color_temperature_kelvin_to_mired( + self._attr_color_temp = color_util.color_temperature_kelvin_to_mired( self._device.status.color_temperature ) # Color if ColorMode.HS in self._attr_supported_color_modes: - self._hs_color = ( + self._attr_hs_color = ( convert_scale(self._device.status.hue, 100, 360), self._device.status.saturation, ) @@ -197,42 +204,11 @@ def color_mode(self) -> ColorMode: return list(self._attr_supported_color_modes)[0] # The light supports hs + color temp, determine which one it is - if self._hs_color and self._hs_color[1]: + if self._attr_hs_color and self._attr_hs_color[1]: return ColorMode.HS return ColorMode.COLOR_TEMP - @property - def brightness(self): - """Return the brightness of this light between 0..255.""" - return self._brightness - - @property - def color_temp(self): - """Return the CT color value in mireds.""" - return self._color_temp - - @property - def hs_color(self): - """Return the hue and saturation color value [float, float].""" - return self._hs_color - @property def is_on(self) -> bool: """Return true if light is on.""" return self._device.status.switch - - @property - def max_mireds(self): - """Return the warmest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # lowest kelvin found supported across 20+ handlers. - return 500 # 2000K - - @property - def min_mireds(self): - """Return the coldest color_temp that this light supports.""" - # SmartThings does not expose this attribute, instead it's - # implemented within each device-type handler. This value is the - # highest kelvin found supported across 20+ handlers. - return 111 # 9000K diff --git a/homeassistant/components/smartthings/scene.py b/homeassistant/components/smartthings/scene.py index 9ccda5fd5e6782..ffdb900237e744 100644 --- a/homeassistant/components/smartthings/scene.py +++ b/homeassistant/components/smartthings/scene.py @@ -25,6 +25,8 @@ class SmartThingsScene(Scene): def __init__(self, scene): """Init the scene class.""" self._scene = scene + self._attr_name = scene.name + self._attr_unique_id = scene.scene_id async def async_activate(self, **kwargs: Any) -> None: """Activate scene.""" @@ -38,13 +40,3 @@ def extra_state_attributes(self): "color": self._scene.color, "location_id": self._scene.location_id, } - - @property - def name(self) -> str: - """Return the name of the device.""" - return self._scene.name - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._scene.scene_id diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 823ca793972c31..18016a88d29405 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -629,44 +629,30 @@ def __init__( attribute: str, name: str, default_unit: str, - device_class: str, + device_class: SensorDeviceClass, state_class: str | None, entity_category: EntityCategory | None, ) -> None: """Init the class.""" super().__init__(device) self._attribute = attribute - self._name = name - self._device_class = device_class + self._attr_name = f"{device.label} {name}" + self._attr_unique_id = f"{device.device_id}.{attribute}" + self._attr_device_class = device_class self._default_unit = default_unit self._attr_state_class = state_class self._attr_entity_category = entity_category - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self._name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self._attribute}" - @property def native_value(self): """Return the state of the sensor.""" value = self._device.status.attributes[self._attribute].value - if self._device_class != SensorDeviceClass.TIMESTAMP: + if self.device_class != SensorDeviceClass.TIMESTAMP: return value return dt_util.parse_datetime(value) - @property - def device_class(self): - """Return the device class of the sensor.""" - return self._device_class - @property def native_unit_of_measurement(self): """Return the unit this state is expressed in.""" @@ -681,16 +667,8 @@ def __init__(self, device, index): """Init the class.""" super().__init__(device) self._index = index - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {THREE_AXIS_NAMES[self._index]}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{THREE_AXIS_NAMES[self._index]}" + self._attr_name = f"{device.label} {THREE_AXIS_NAMES[index]}" + self._attr_unique_id = f"{device.device_id} {THREE_AXIS_NAMES[index]}" @property def native_value(self): @@ -713,19 +691,16 @@ def __init__( """Init the class.""" super().__init__(device) self.report_name = report_name - self._attr_state_class = SensorStateClass.MEASUREMENT - if self.report_name != "power": + self._attr_name = f"{device.label} {report_name}" + self._attr_unique_id = f"{device.device_id}.{report_name}_meter" + if self.report_name == "power": + self._attr_state_class = SensorStateClass.MEASUREMENT + self._attr_device_class = SensorDeviceClass.POWER + self._attr_native_unit_of_measurement = UnitOfPower.WATT + else: self._attr_state_class = SensorStateClass.TOTAL_INCREASING - - @property - def name(self) -> str: - """Return the name of the sensor.""" - return f"{self._device.label} {self.report_name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return f"{self._device.device_id}.{self.report_name}_meter" + self._attr_device_class = SensorDeviceClass.ENERGY + self._attr_native_unit_of_measurement = UnitOfEnergy.KILO_WATT_HOUR @property def native_value(self): @@ -737,20 +712,6 @@ def native_value(self): return value[self.report_name] return value[self.report_name] / 1000 - @property - def device_class(self): - """Return the device class of the sensor.""" - if self.report_name == "power": - return SensorDeviceClass.POWER - return SensorDeviceClass.ENERGY - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - if self.report_name == "power": - return UnitOfPower.WATT - return UnitOfEnergy.KILO_WATT_HOUR - @property def extra_state_attributes(self): """Return specific state attributes.""" From 44af34083bda14fefd1463bb41d3ac296cdce80f Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 19:27:53 +0200 Subject: [PATCH 406/640] Remove unnecessary pylint disable in tado (#100196) --- homeassistant/components/tado/sensor.py | 91 ++++++++++++------------- 1 file changed, 44 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/tado/sensor.py b/homeassistant/components/tado/sensor.py index f7ba1682e18578..c665cc3c592f82 100644 --- a/homeassistant/components/tado/sensor.py +++ b/homeassistant/components/tado/sensor.py @@ -52,6 +52,47 @@ class TadoSensorEntityDescription( data_category: str | None = None +def format_condition(condition: str) -> str: + """Return condition from dict CONDITIONS_MAP.""" + for key, value in CONDITIONS_MAP.items(): + if condition in value: + return key + return condition + + +def get_tado_mode(data) -> str | None: + """Return Tado Mode based on Presence attribute.""" + if "presence" in data: + return data["presence"] + return None + + +def get_automatic_geofencing(data) -> bool: + """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" + if "presenceLocked" in data: + if data["presenceLocked"]: + return False + return True + return False + + +def get_geofencing_mode(data) -> str: + """Return Geofencing Mode based on Presence and Presence Locked attributes.""" + tado_mode = "" + tado_mode = data.get("presence", "unknown") + + geofencing_switch_mode = "" + if "presenceLocked" in data: + if data["presenceLocked"]: + geofencing_switch_mode = "manual" + else: + geofencing_switch_mode = "auto" + else: + geofencing_switch_mode = "manual" + + return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" + + HOME_SENSORS = [ TadoSensorEntityDescription( key="outdoor temperature", @@ -86,22 +127,19 @@ class TadoSensorEntityDescription( TadoSensorEntityDescription( key="tado mode", translation_key="tado_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_tado_mode(data), + state_fn=get_tado_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="geofencing mode", translation_key="geofencing_mode", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_geofencing_mode(data), + state_fn=get_geofencing_mode, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), TadoSensorEntityDescription( key="automatic geofencing", translation_key="automatic_geofencing", - # pylint: disable=unnecessary-lambda - state_fn=lambda data: get_automatic_geofencing(data), + state_fn=get_automatic_geofencing, data_category=SENSOR_DATA_CATEGORY_GEOFENCE, ), ] @@ -163,47 +201,6 @@ class TadoSensorEntityDescription( } -def format_condition(condition: str) -> str: - """Return condition from dict CONDITIONS_MAP.""" - for key, value in CONDITIONS_MAP.items(): - if condition in value: - return key - return condition - - -def get_tado_mode(data) -> str | None: - """Return Tado Mode based on Presence attribute.""" - if "presence" in data: - return data["presence"] - return None - - -def get_automatic_geofencing(data) -> bool: - """Return whether Automatic Geofencing is enabled based on Presence Locked attribute.""" - if "presenceLocked" in data: - if data["presenceLocked"]: - return False - return True - return False - - -def get_geofencing_mode(data) -> str: - """Return Geofencing Mode based on Presence and Presence Locked attributes.""" - tado_mode = "" - tado_mode = data.get("presence", "unknown") - - geofencing_switch_mode = "" - if "presenceLocked" in data: - if data["presenceLocked"]: - geofencing_switch_mode = "manual" - else: - geofencing_switch_mode = "auto" - else: - geofencing_switch_mode = "manual" - - return f"{tado_mode.capitalize()} ({geofencing_switch_mode.capitalize()})" - - async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: From f5c0c7bf27f0cc144f17ce76492c3f880b521731 Mon Sep 17 00:00:00 2001 From: hahn-th <15319212+hahn-th@users.noreply.github.com> Date: Tue, 12 Sep 2023 19:33:42 +0200 Subject: [PATCH 407/640] Bump homematicip_cloud to 1.0.15 (#99387) --- homeassistant/components/homematicip_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/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 1b86e36b826799..c3d14b7d38314a 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -7,5 +7,5 @@ "iot_class": "cloud_push", "loggers": ["homematicip"], "quality_scale": "silver", - "requirements": ["homematicip==1.0.14"] + "requirements": ["homematicip==1.0.15"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3c5ef0694b27c9..9b3192459aaa67 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1007,7 +1007,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ec4ec481af6e51..656658f254fc78 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -793,7 +793,7 @@ home-assistant-intents==2023.8.2 homeconnect==0.7.2 # homeassistant.components.homematicip_cloud -homematicip==1.0.14 +homematicip==1.0.15 # homeassistant.components.home_plus_control homepluscontrol==0.0.5 From 8af7475f73ff0c2d49d091d9f312e038b37ce90a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:36:56 -0500 Subject: [PATCH 408/640] Set TriggerBaseEntity device_class in init (#100216) --- homeassistant/helpers/trigger_template_entity.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/helpers/trigger_template_entity.py b/homeassistant/helpers/trigger_template_entity.py index 0ee653b42bdfc6..bc7deceefefa74 100644 --- a/homeassistant/helpers/trigger_template_entity.py +++ b/homeassistant/helpers/trigger_template_entity.py @@ -119,6 +119,7 @@ def __init__( # We make a copy so our initial render is 'unknown' and not 'unavailable' self._rendered = dict(self._static_rendered) self._parse_result = {CONF_AVAILABILITY} + self._attr_device_class = config.get(CONF_DEVICE_CLASS) @property def name(self) -> str | None: @@ -130,11 +131,6 @@ def unique_id(self) -> str | None: """Return unique ID of the entity.""" return self._unique_id - @property - def device_class(self): # type: ignore[no-untyped-def] - """Return device class of the entity.""" - return self._config.get(CONF_DEVICE_CLASS) - @property def icon(self) -> str | None: """Return icon.""" From 5021c6988699e1994127ffe8d46d93efa803e551 Mon Sep 17 00:00:00 2001 From: uvjustin <46082645+uvjustin@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:38:11 +0800 Subject: [PATCH 409/640] Update Stream logging on EVENT_LOGGING_CHANGED (#99256) --- homeassistant/components/stream/__init__.py | 42 +++++++++---------- homeassistant/components/stream/manifest.json | 2 +- pyproject.toml | 1 + tests/components/stream/test_init.py | 9 +++- 4 files changed, 29 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 691ba262ee2371..626a03b785f4be 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -29,6 +29,7 @@ import voluptuous as vol from yarl import URL +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -188,36 +189,32 @@ def convert_stream_options( ) -def filter_libav_logging() -> None: - """Filter libav logging to only log when the stream logger is at DEBUG.""" +@callback +def update_pyav_logging(_event: Event | None = None) -> None: + """Adjust libav logging to only log when the stream logger is at DEBUG.""" - def libav_filter(record: logging.LogRecord) -> bool: - return logging.getLogger(__name__).isEnabledFor(logging.DEBUG) + def set_pyav_logging(enable: bool) -> None: + """Turn PyAV logging on or off.""" + import av # pylint: disable=import-outside-toplevel - for logging_namespace in ( - "libav.NULL", - "libav.h264", - "libav.hevc", - "libav.hls", - "libav.mp4", - "libav.mpegts", - "libav.rtsp", - "libav.tcp", - "libav.tls", - ): - logging.getLogger(logging_namespace).addFilter(libav_filter) + av.logging.set_level(av.logging.VERBOSE if enable else av.logging.FATAL) - # Set log level to error for libav.mp4 - logging.getLogger("libav.mp4").setLevel(logging.ERROR) - # Suppress "deprecated pixel format" WARNING - logging.getLogger("libav.swscaler").setLevel(logging.ERROR) + # enable PyAV logging iff Stream logger is set to debug + set_pyav_logging(logging.getLogger(__name__).isEnabledFor(logging.DEBUG)) async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up stream.""" - # Drop libav log messages if stream logging is above DEBUG - filter_libav_logging() + # Only pass through PyAV log messages if stream logging is above DEBUG + cancel_logging_listener = hass.bus.async_listen( + EVENT_LOGGING_CHANGED, update_pyav_logging + ) + # libav.mp4 and libav.swscaler have a few unimportant messages that are logged + # at logging.WARNING. Set those Logger levels to logging.ERROR + for logging_namespace in ("libav.mp4", "libav.swscaler"): + logging.getLogger(logging_namespace).setLevel(logging.ERROR) + update_pyav_logging() # Keep import here so that we can import stream integration without installing reqs # pylint: disable-next=import-outside-toplevel @@ -258,6 +255,7 @@ async def shutdown(event: Event) -> None: ]: await asyncio.wait(awaitables) _LOGGER.debug("Stopped stream workers") + cancel_logging_listener() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) diff --git a/homeassistant/components/stream/manifest.json b/homeassistant/components/stream/manifest.json index 96474ceb7eb7b9..47a4ddd0653be6 100644 --- a/homeassistant/components/stream/manifest.json +++ b/homeassistant/components/stream/manifest.json @@ -2,7 +2,7 @@ "domain": "stream", "name": "Stream", "codeowners": ["@hunterjm", "@uvjustin", "@allenporter"], - "dependencies": ["http"], + "dependencies": ["http", "logger"], "documentation": "https://www.home-assistant.io/integrations/stream", "integration_type": "system", "iot_class": "local_push", diff --git a/pyproject.toml b/pyproject.toml index e62bdbf3e30e4b..73f47998ea7943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -109,6 +109,7 @@ load-plugins = [ persistent = false extension-pkg-allow-list = [ "av.audio.stream", + "av.logging", "av.stream", "ciso8601", "orjson", diff --git a/tests/components/stream/test_init.py b/tests/components/stream/test_init.py index 0c625a8dec167c..525eb9d859d59b 100644 --- a/tests/components/stream/test_init.py +++ b/tests/components/stream/test_init.py @@ -4,6 +4,7 @@ import av import pytest +from homeassistant.components.logger import EVENT_LOGGING_CHANGED from homeassistant.components.stream import __name__ as stream_name from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component @@ -14,8 +15,6 @@ async def test_log_levels( ) -> None: """Test that the worker logs the url without username and password.""" - logging.getLogger(stream_name).setLevel(logging.INFO) - await async_setup_component(hass, "stream", {"stream": {}}) # These namespaces should only pass log messages when the stream logger @@ -31,11 +30,17 @@ async def test_log_levels( "NULL", ) + logging.getLogger(stream_name).setLevel(logging.INFO) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() + # Since logging is at INFO, these should not pass for namespace in namespaces_to_toggle: av.logging.log(av.logging.ERROR, namespace, "SHOULD NOT PASS") logging.getLogger(stream_name).setLevel(logging.DEBUG) + hass.bus.async_fire(EVENT_LOGGING_CHANGED) + await hass.async_block_till_done() # Since logging is now at DEBUG, these should now pass for namespace in namespaces_to_toggle: From 74a57e86768b79ce95ebf9c581666f3be5799e33 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Tue, 12 Sep 2023 19:44:31 +0200 Subject: [PATCH 410/640] Use more common translations (#100135) --- homeassistant/components/bosch_shc/strings.json | 3 +++ homeassistant/components/climate/strings.json | 4 ++-- homeassistant/components/co2signal/strings.json | 2 +- homeassistant/components/dlink/strings.json | 12 +++++++++--- homeassistant/components/forecast_solar/strings.json | 2 +- .../homeassistant_sky_connect/strings.json | 2 +- .../components/homeassistant_yellow/strings.json | 2 +- homeassistant/components/hue/strings.json | 2 +- homeassistant/components/humidifier/strings.json | 4 ++-- homeassistant/components/jvc_projector/strings.json | 2 +- homeassistant/components/kodi/strings.json | 4 ++-- homeassistant/components/lawn_mower/strings.json | 2 +- homeassistant/components/mikrotik/strings.json | 2 +- homeassistant/components/netgear/strings.json | 4 ++-- homeassistant/components/octoprint/strings.json | 4 ++-- homeassistant/components/plugwise/strings.json | 3 +++ homeassistant/components/ps4/strings.json | 2 +- homeassistant/components/qnap/strings.json | 12 ++++++------ homeassistant/components/shelly/strings.json | 2 +- homeassistant/components/subaru/strings.json | 2 +- homeassistant/components/text/strings.json | 4 ++-- homeassistant/components/tibber/strings.json | 2 +- homeassistant/components/vulcan/strings.json | 8 ++++---- 23 files changed, 49 insertions(+), 37 deletions(-) diff --git a/homeassistant/components/bosch_shc/strings.json b/homeassistant/components/bosch_shc/strings.json index 67462b78bec32a..90688e1373ff5d 100644 --- a/homeassistant/components/bosch_shc/strings.json +++ b/homeassistant/components/bosch_shc/strings.json @@ -10,6 +10,9 @@ }, "credentials": { "data": { + "password": "[%key:common::config_flow::data::password%]" + }, + "data_description": { "password": "Password of the Smart Home Controller" } }, diff --git a/homeassistant/components/climate/strings.json b/homeassistant/components/climate/strings.json index c517bfd7a20b06..55ccef2bc76254 100644 --- a/homeassistant/components/climate/strings.json +++ b/homeassistant/components/climate/strings.json @@ -66,7 +66,7 @@ "heating": "Heating", "cooling": "Cooling", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "fan": "Fan" } }, @@ -93,7 +93,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "activity": "Activity" } diff --git a/homeassistant/components/co2signal/strings.json b/homeassistant/components/co2signal/strings.json index 01c5673d4b1b11..7dbcd2e7966e89 100644 --- a/homeassistant/components/co2signal/strings.json +++ b/homeassistant/components/co2signal/strings.json @@ -3,7 +3,7 @@ "step": { "user": { "data": { - "location": "Get data for", + "location": "[%key:common::config_flow::data::location%]", "api_key": "[%key:common::config_flow::data::access_token%]" }, "description": "Visit https://electricitymaps.com/free-tier to request a token." diff --git a/homeassistant/components/dlink/strings.json b/homeassistant/components/dlink/strings.json index ee7abb3e97907d..8c60d59fa6b2f5 100644 --- a/homeassistant/components/dlink/strings.json +++ b/homeassistant/components/dlink/strings.json @@ -4,21 +4,27 @@ "user": { "data": { "host": "[%key:common::config_flow::data::host%]", - "password": "Password (default: PIN code on the back)", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "Use legacy protocol" + }, + "data_description": { + "password": "Default: PIN code on the back." } }, "confirm_discovery": { "data": { - "password": "[%key:component::dlink::config::step::user::data::password%]", + "password": "[%key:common::config_flow::data::password%]", "username": "[%key:common::config_flow::data::username%]", "use_legacy_protocol": "[%key:component::dlink::config::step::user::data::use_legacy_protocol%]" + }, + "data_description": { + "password": "[%key:component::dlink::config::step::user::data_description::password%]" } } }, "error": { - "cannot_connect": "Failed to connect/authenticate", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { diff --git a/homeassistant/components/forecast_solar/strings.json b/homeassistant/components/forecast_solar/strings.json index 1413dba23d4357..201a3cd415ceef 100644 --- a/homeassistant/components/forecast_solar/strings.json +++ b/homeassistant/components/forecast_solar/strings.json @@ -22,7 +22,7 @@ "init": { "description": "These values allow tweaking the Forecast.Solar result. Please refer to the documentation if a field is unclear.", "data": { - "api_key": "Forecast.Solar API Key (optional)", + "api_key": "[%key:common::config_flow::data::api_key%]", "azimuth": "[%key:component::forecast_solar::config::step::user::data::azimuth%]", "damping_morning": "Damping factor: adjusts the results in the morning", "damping_evening": "Damping factor: adjusts the results in the evening", diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 58fc0180743306..2ed0026a48ce43 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -60,7 +60,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 68e87c06024458..894d799d0735e0 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -82,7 +82,7 @@ } }, "error": { - "unknown": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::error::unknown%]" + "unknown": "[%key:common::config_flow::error::unknown%]" }, "abort": { "addon_info_failed": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::abort::addon_info_failed%]", diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 6d65abc8d5f3b3..326d08d1f7a3c2 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -31,7 +31,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", "not_hue_bridge": "Not a Hue bridge", - "invalid_host": "Invalid host" + "invalid_host": "[%key:common::config_flow::error::invalid_host%]" } }, "device_automation": { diff --git a/homeassistant/components/humidifier/strings.json b/homeassistant/components/humidifier/strings.json index 19a9a8eab77233..1cdad10f2fb14b 100644 --- a/homeassistant/components/humidifier/strings.json +++ b/homeassistant/components/humidifier/strings.json @@ -33,7 +33,7 @@ "state": { "humidifying": "Humidifying", "drying": "Drying", - "idle": "Idle", + "idle": "[%key:common::state::idle%]", "off": "[%key:common::state::off%]" } }, @@ -60,7 +60,7 @@ "away": "Away", "boost": "Boost", "comfort": "Comfort", - "home": "Home", + "home": "[%key:common::state::home%]", "sleep": "Sleep", "auto": "Auto", "baby": "Baby" diff --git a/homeassistant/components/jvc_projector/strings.json b/homeassistant/components/jvc_projector/strings.json index 1f85c20fc72222..6fdc5b4d12f560 100644 --- a/homeassistant/components/jvc_projector/strings.json +++ b/homeassistant/components/jvc_projector/strings.json @@ -29,7 +29,7 @@ "error": { "invalid_host": "[%key:common::config_flow::error::invalid_host%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:component::jvc_projector::config::step::reauth_confirm::description%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" } } } diff --git a/homeassistant/components/kodi/strings.json b/homeassistant/components/kodi/strings.json index f7ee375f9902f0..51431b317d6488 100644 --- a/homeassistant/components/kodi/strings.json +++ b/homeassistant/components/kodi/strings.json @@ -43,8 +43,8 @@ }, "device_automation": { "trigger_type": { - "turn_on": "[%key:common::device_automation::action_type::turn_on%]", - "turn_off": "[%key:common::device_automation::action_type::turn_off%]" + "turn_on": "[%key:common::device_automation::trigger_type::turned_on%]", + "turn_off": "[%key:common::device_automation::trigger_type::turned_off%]" } }, "services": { diff --git a/homeassistant/components/lawn_mower/strings.json b/homeassistant/components/lawn_mower/strings.json index caf2e15df779c2..15ed50ca6c5cd3 100644 --- a/homeassistant/components/lawn_mower/strings.json +++ b/homeassistant/components/lawn_mower/strings.json @@ -5,7 +5,7 @@ "name": "[%key:component::lawn_mower::title%]", "state": { "error": "Error", - "paused": "Paused", + "paused": "[%key:common::state::paused%]", "mowing": "Mowing", "docked": "Docked" } diff --git a/homeassistant/components/mikrotik/strings.json b/homeassistant/components/mikrotik/strings.json index ec47d98b7a9f5e..582450eca62b21 100644 --- a/homeassistant/components/mikrotik/strings.json +++ b/homeassistant/components/mikrotik/strings.json @@ -9,7 +9,7 @@ "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "verify_ssl": "Use ssl" + "verify_ssl": "[%key:common::config_flow::data::ssl%]" } }, "reauth_confirm": { diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index 7941d1fe0a7c6b..f2af3dd7804a99 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -4,8 +4,8 @@ "user": { "description": "Default host: {host}\nDefault username: {username}", "data": { - "host": "Host (Optional)", - "username": "Username (Optional)", + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]" } } diff --git a/homeassistant/components/octoprint/strings.json b/homeassistant/components/octoprint/strings.json index 23cdf6ce56ef53..c6dbfe6f9c42f9 100644 --- a/homeassistant/components/octoprint/strings.json +++ b/homeassistant/components/octoprint/strings.json @@ -6,8 +6,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "path": "Application Path", - "port": "Port Number", - "ssl": "Use SSL", + "port": "[%key:common::config_flow::data::port%]", + "ssl": "[%key:common::config_flow::data::ssl%]", "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]", "username": "[%key:common::config_flow::data::username%]" } diff --git a/homeassistant/components/plugwise/strings.json b/homeassistant/components/plugwise/strings.json index 2714d657267b29..f85c83819fac04 100644 --- a/homeassistant/components/plugwise/strings.json +++ b/homeassistant/components/plugwise/strings.json @@ -9,6 +9,9 @@ "host": "[%key:common::config_flow::data::ip%]", "port": "[%key:common::config_flow::data::port%]", "username": "Smile Username" + }, + "data_description": { + "host": "Leave empty if using Auto Discovery" } } }, diff --git a/homeassistant/components/ps4/strings.json b/homeassistant/components/ps4/strings.json index 644b2d61216100..163f2cc9b94c0f 100644 --- a/homeassistant/components/ps4/strings.json +++ b/homeassistant/components/ps4/strings.json @@ -7,7 +7,7 @@ "mode": { "data": { "mode": "Config Mode", - "ip_address": "IP address (Leave empty if using Auto Discovery)." + "ip_address": "[%key:common::config_flow::data::ip%]" }, "data_description": { "ip_address": "Leave blank if selecting auto-discovery." diff --git a/homeassistant/components/qnap/strings.json b/homeassistant/components/qnap/strings.json index 64b3f22293aee4..a5fa3c8a8971ff 100644 --- a/homeassistant/components/qnap/strings.json +++ b/homeassistant/components/qnap/strings.json @@ -5,19 +5,19 @@ "title": "Connect to the QNAP device", "description": "This qnap sensor allows getting various statistics from your QNAP NAS.", "data": { - "host": "Hostname", + "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "port": "[%key:common::config_flow::data::port%]", - "ssl": "Enable SSL", - "verify_ssl": "Verify SSL" + "ssl": "[%key:common::config_flow::data::ssl%]", + "verify_ssl": "[%key:common::config_flow::data::verify_ssl%]" } } }, "error": { - "cannot_connect": "Cannot connect to host", - "invalid_auth": "Bad authentication", - "unknown": "Unknown error" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" } } } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index 043ff419742d2b..dcdfa6d7987bb0 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -120,7 +120,7 @@ "valve_status": { "state": { "checking": "Checking", - "closed": "Closed", + "closed": "[%key:common::state::closed%]", "closing": "Closing", "failure": "Failure", "opened": "Opened", diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 5e6db32d4add11..78625192e4a58f 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -28,7 +28,7 @@ "title": "[%key:component::subaru::config::step::user::title%]", "description": "Please enter your MySubaru PIN\nNOTE: All vehicles in account must have the same PIN", "data": { - "pin": "PIN" + "pin": "[%key:common::config_flow::data::pin%]" } } }, diff --git a/homeassistant/components/text/strings.json b/homeassistant/components/text/strings.json index e6b3d99ced4a5d..82cab559d0e568 100644 --- a/homeassistant/components/text/strings.json +++ b/homeassistant/components/text/strings.json @@ -16,10 +16,10 @@ "name": "Min length" }, "mode": { - "name": "Mode", + "name": "[%key:common::config_flow::data::mode%]", "state": { "text": "Text", - "password": "Password" + "password": "[%key:common::config_flow::data::password%]" } }, "pattern": { diff --git a/homeassistant/components/tibber/strings.json b/homeassistant/components/tibber/strings.json index 2876bf5bd022c3..8306f25f587afd 100644 --- a/homeassistant/components/tibber/strings.json +++ b/homeassistant/components/tibber/strings.json @@ -4,7 +4,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" }, "error": { - "timeout": "Timeout connecting to Tibber", + "timeout": "[%key:common::config_flow::error::timeout_connect%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" }, diff --git a/homeassistant/components/vulcan/strings.json b/homeassistant/components/vulcan/strings.json index b2b270e3422fc7..07a0510f48231b 100644 --- a/homeassistant/components/vulcan/strings.json +++ b/homeassistant/components/vulcan/strings.json @@ -7,13 +7,13 @@ "no_matching_entries": "No matching entries found, please use different account or remove integration with outdated student.." }, "error": { - "unknown": "Unknown error occurred", - "invalid_token": "Invalid token", + "unknown": "[%key:common::config_flow::error::unknown%]", + "invalid_token": "[%key:common::config_flow::error::invalid_access_token%]", "expired_token": "Expired token - please generate a new token", "invalid_pin": "Invalid pin", "invalid_symbol": "Invalid symbol", "expired_credentials": "Expired credentials - please create new on Vulcan mobile app registration page", - "cannot_connect": "Connection error - please check your internet connection" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" }, "step": { "auth": { @@ -21,7 +21,7 @@ "data": { "token": "Token", "region": "Symbol", - "pin": "Pin" + "pin": "[%key:common::config_flow::data::pin%]" } }, "reauth_confirm": { From 6a7d5a0fd43e2816ca8a2aa3e30b66cbbdf48843 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:45:44 -0500 Subject: [PATCH 411/640] Use more shorthand attributes in huawei_lte binary_sensor (#100211) --- .../components/huawei_lte/binary_sensor.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index 9966b9cc5f5df5..a1a26b516573d2 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -52,15 +52,12 @@ async def async_setup_entry( class HuaweiLteBaseBinarySensor(HuaweiLteBaseEntityWithDevice, BinarySensorEntity): """Huawei LTE binary sensor device base class.""" + _attr_entity_registry_enabled_default = False + key: str = field(init=False) item: str = field(init=False) _raw_state: str | None = field(default=None, init=False) - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - @property def _device_unique_id(self) -> str: return f"{self.key}.{self.item}" @@ -106,6 +103,7 @@ class HuaweiLteMobileConnectionBinarySensor(HuaweiLteBaseBinarySensor): """Huawei LTE mobile connection binary sensor.""" _attr_name: str = field(default="Mobile connection", init=False) + _attr_entity_registry_enabled_default = True def __post_init__(self) -> None: """Initialize identifiers.""" @@ -135,11 +133,6 @@ def icon(self) -> str: """Return mobile connectivity sensor icon.""" return "mdi:signal" if self.is_on else "mdi:signal-off" - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return True - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Get additional attributes related to connection status.""" From 42c35da81850570aae576898acf54729bce3e16a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:45:53 -0500 Subject: [PATCH 412/640] Use more shorthand properties in homematicip_cloud (#100210) --- .../homematicip_cloud/binary_sensor.py | 74 +++++-------------- .../components/homematicip_cloud/cover.py | 26 ++----- .../components/homematicip_cloud/weather.py | 12 +-- 3 files changed, 28 insertions(+), 84 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 6730f722685348..2afe803e1ebd11 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -194,10 +194,7 @@ def available(self) -> bool: class HomematicipBaseActionSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP base action sensor.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOVING + _attr_device_class = BinarySensorDeviceClass.MOVING @property def is_on(self) -> bool: @@ -227,6 +224,8 @@ class HomematicipTiltVibrationSensor(HomematicipBaseActionSensor): class HomematicipMultiContactInterface(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP multi room/area contact interface.""" + _attr_device_class = BinarySensorDeviceClass.OPENING + def __init__( self, hap: HomematicipHAP, @@ -239,11 +238,6 @@ def __init__( hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.OPENING - @property def is_on(self) -> bool | None: """Return true if the contact interface is on/open.""" @@ -266,6 +260,8 @@ def __init__(self, hap: HomematicipHAP, device) -> None: class HomematicipShutterContact(HomematicipMultiContactInterface, BinarySensorEntity): """Representation of the HomematicIP shutter contact.""" + _attr_device_class = BinarySensorDeviceClass.DOOR + def __init__( self, hap: HomematicipHAP, device, has_additional_state: bool = False ) -> None: @@ -273,11 +269,6 @@ def __init__( super().__init__(hap, device, is_multi_channel=False) self.has_additional_state = has_additional_state - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.DOOR - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the Shutter Contact.""" @@ -294,10 +285,7 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipMotionDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP motion detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOTION + _attr_device_class = BinarySensorDeviceClass.MOTION @property def is_on(self) -> bool: @@ -308,10 +296,7 @@ def is_on(self) -> bool: class HomematicipPresenceDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP presence detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.PRESENCE + _attr_device_class = BinarySensorDeviceClass.PRESENCE @property def is_on(self) -> bool: @@ -322,10 +307,7 @@ def is_on(self) -> bool: class HomematicipSmokeDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP smoke detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SMOKE + _attr_device_class = BinarySensorDeviceClass.SMOKE @property def is_on(self) -> bool: @@ -341,10 +323,7 @@ def is_on(self) -> bool: class HomematicipWaterDetector(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP water detector.""" - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE + _attr_device_class = BinarySensorDeviceClass.MOISTURE @property def is_on(self) -> bool: @@ -373,15 +352,12 @@ def is_on(self) -> bool: class HomematicipRainSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP rain sensor.""" + _attr_device_class = BinarySensorDeviceClass.MOISTURE + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize rain sensor.""" super().__init__(hap, device, "Raining") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.MOISTURE - @property def is_on(self) -> bool: """Return true, if it is raining.""" @@ -391,15 +367,12 @@ def is_on(self) -> bool: class HomematicipSunshineSensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP sunshine sensor.""" + _attr_device_class = BinarySensorDeviceClass.LIGHT + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize sunshine sensor.""" super().__init__(hap, device, post="Sunshine") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.LIGHT - @property def is_on(self) -> bool: """Return true if sun is shining.""" @@ -420,15 +393,12 @@ def extra_state_attributes(self) -> dict[str, Any]: class HomematicipBatterySensor(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP low battery sensor.""" + _attr_device_class = BinarySensorDeviceClass.BATTERY + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize battery sensor.""" super().__init__(hap, device, post="Battery") - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.BATTERY - @property def is_on(self) -> bool: """Return true if battery is low.""" @@ -440,15 +410,12 @@ class HomematicipPluggableMainsFailureSurveillanceSensor( ): """Representation of the HomematicIP pluggable mains failure surveillance sensor.""" + _attr_device_class = BinarySensorDeviceClass.POWER + def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize pluggable mains failure surveillance sensor.""" super().__init__(hap, device) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.POWER - @property def is_on(self) -> bool: """Return true if power mains fails.""" @@ -458,16 +425,13 @@ def is_on(self) -> bool: class HomematicipSecurityZoneSensorGroup(HomematicipGenericEntity, BinarySensorEntity): """Representation of the HomematicIP security zone sensor group.""" + _attr_device_class = BinarySensorDeviceClass.SAFETY + def __init__(self, hap: HomematicipHAP, device, post: str = "SecurityZone") -> None: """Initialize security zone group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post=post) - @property - def device_class(self) -> BinarySensorDeviceClass: - """Return the class of this sensor.""" - return BinarySensorDeviceClass.SAFETY - @property def available(self) -> bool: """Security-Group available.""" diff --git a/homeassistant/components/homematicip_cloud/cover.py b/homeassistant/components/homematicip_cloud/cover.py index e5007b5a15f02b..f5a9919579cabf 100644 --- a/homeassistant/components/homematicip_cloud/cover.py +++ b/homeassistant/components/homematicip_cloud/cover.py @@ -68,10 +68,7 @@ async def async_setup_entry( class HomematicipBlindModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP blind module.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.BLIND + _attr_device_class = CoverDeviceClass.BLIND @property def current_cover_position(self) -> int | None: @@ -149,6 +146,8 @@ async def async_stop_cover_tilt(self, **kwargs: Any) -> None: class HomematicipMultiCoverShutter(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__( self, hap: HomematicipHAP, @@ -161,11 +160,6 @@ def __init__( hap, device, channel=channel, is_multi_channel=is_multi_channel ) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -272,6 +266,8 @@ def __init__(self, hap: HomematicipHAP, device) -> None: class HomematicipGarageDoorModule(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP Garage Door Module.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def current_cover_position(self) -> int | None: """Return current position of cover.""" @@ -283,11 +279,6 @@ def current_cover_position(self) -> int | None: } return door_state_to_position.get(self._device.doorState) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.GARAGE - @property def is_closed(self) -> bool | None: """Return if the cover is closed.""" @@ -309,16 +300,13 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class HomematicipCoverShutterGroup(HomematicipGenericEntity, CoverEntity): """Representation of the HomematicIP cover shutter group.""" + _attr_device_class = CoverDeviceClass.SHUTTER + def __init__(self, hap: HomematicipHAP, device, post: str = "ShutterGroup") -> None: """Initialize switching group.""" device.modelType = f"HmIP-{post}" super().__init__(hap, device, post, is_multi_channel=False) - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the cover.""" - return CoverDeviceClass.SHUTTER - @property def current_cover_position(self) -> int | None: """Return current position of cover.""" diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py index e913e1125f162c..573f291d55734b 100644 --- a/homeassistant/components/homematicip_cloud/weather.py +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -72,6 +72,7 @@ class HomematicipWeatherSensor(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP, device) -> None: """Initialize the weather sensor.""" @@ -97,11 +98,6 @@ def native_wind_speed(self) -> float: """Return the wind speed.""" return self._device.windSpeed - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str: """Return the current condition.""" @@ -128,6 +124,7 @@ class HomematicipHomeWeather(HomematicipGenericEntity, WeatherEntity): _attr_native_temperature_unit = UnitOfTemperature.CELSIUS _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_attribution = "Powered by Homematic IP" def __init__(self, hap: HomematicipHAP) -> None: """Initialize the home weather.""" @@ -164,11 +161,6 @@ def wind_bearing(self) -> float: """Return the wind bearing.""" return self._device.weather.windDirection - @property - def attribution(self) -> str: - """Return the attribution.""" - return "Powered by Homematic IP" - @property def condition(self) -> str | None: """Return the current condition.""" From 9c775a8a24985cbba280ff1292efefe95e7df44a Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 12:58:20 -0500 Subject: [PATCH 413/640] Set roku media player device class in constructor (#100225) --- homeassistant/components/roku/media_player.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/roku/media_player.py b/homeassistant/components/roku/media_player.py index 05f782b37c452e..62a1a1814599d6 100644 --- a/homeassistant/components/roku/media_player.py +++ b/homeassistant/components/roku/media_player.py @@ -122,6 +122,14 @@ class RokuMediaPlayer(RokuEntity, MediaPlayerEntity): | MediaPlayerEntityFeature.BROWSE_MEDIA ) + def __init__(self, coordinator: RokuDataUpdateCoordinator) -> None: + """Initialize the Roku device.""" + super().__init__(coordinator=coordinator) + if coordinator.data.info.device_type == "tv": + self._attr_device_class = MediaPlayerDeviceClass.TV + else: + self._attr_device_class = MediaPlayerDeviceClass.RECEIVER + def _media_playback_trackable(self) -> bool: """Detect if we have enough media data to track playback.""" if self.coordinator.data.media is None or self.coordinator.data.media.live: @@ -129,14 +137,6 @@ def _media_playback_trackable(self) -> bool: return self.coordinator.data.media.duration > 0 - @property - def device_class(self) -> MediaPlayerDeviceClass: - """Return the class of this device.""" - if self.coordinator.data.info.device_type == "tv": - return MediaPlayerDeviceClass.TV - - return MediaPlayerDeviceClass.RECEIVER - @property def state(self) -> MediaPlayerState | None: """Return the state of the device.""" From 69ac8a0a2be7d8d6302c19d9abd96011ce81e9d0 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:05:57 +0200 Subject: [PATCH 414/640] Use shorthand attributes in NWS (#99620) --- homeassistant/components/nws/sensor.py | 25 +++++---------- homeassistant/components/nws/weather.py | 41 +++++-------------------- 2 files changed, 15 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/nws/sensor.py b/homeassistant/components/nws/sensor.py index 7c49ca278a7cf7..ecf9d39ae559d0 100644 --- a/homeassistant/components/nws/sensor.py +++ b/homeassistant/components/nws/sensor.py @@ -23,7 +23,6 @@ UnitOfTemperature, ) 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 from homeassistant.util.dt import utcnow @@ -163,6 +162,7 @@ class NWSSensor(CoordinatorEntity[NwsDataUpdateCoordinator], SensorEntity): entity_description: NWSSensorEntityDescription _attr_attribution = ATTRIBUTION + _attr_entity_registry_enabled_default = False def __init__( self, @@ -175,13 +175,17 @@ def __init__( """Initialise the platform with a data instance.""" super().__init__(nws_data.coordinator_observation) self._nws = nws_data.api - self._latitude = entry_data[CONF_LATITUDE] - self._longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] self.entity_description = description self._attr_name = f"{station} {description.name}" if hass.config.units is US_CUSTOMARY_SYSTEM: self._attr_native_unit_of_measurement = description.unit_convert + self._attr_device_info = device_info(latitude, longitude) + self._attr_unique_id = ( + f"{base_unique_id(latitude, longitude)}_{description.key}" + ) @property def native_value(self) -> float | None: @@ -219,11 +223,6 @@ def native_value(self) -> float | None: return round(value) return value - @property - def unique_id(self) -> str: - """Return a unique_id for this entity.""" - return f"{base_unique_id(self._latitude, self._longitude)}_{self.entity_description.key}" - @property def available(self) -> bool: """Return if state is available.""" @@ -235,13 +234,3 @@ def available(self) -> bool: else: last_success_time = False return self.coordinator.last_update_success or last_success_time - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return False - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self._latitude, self._longitude) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index 0f594133f69933..d68b1fc745c732 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -32,7 +32,6 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import entity_registry as er -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.dt import utcnow from homeassistant.util.unit_conversion import SpeedConverter, TemperatureConverter @@ -121,6 +120,10 @@ class NWSWeather(CoordinatorWeatherEntity): _attr_supported_features = ( WeatherEntityFeature.FORECAST_HOURLY | WeatherEntityFeature.FORECAST_TWICE_DAILY ) + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_pressure_unit = UnitOfPressure.PA + _attr_native_wind_speed_unit = UnitOfSpeed.KILOMETERS_PER_HOUR + _attr_native_visibility_unit = UnitOfLength.METERS def __init__( self, @@ -137,8 +140,8 @@ def __init__( twice_daily_forecast_valid=FORECAST_VALID_TIME, ) self.nws = nws_data.api - self.latitude = entry_data[CONF_LATITUDE] - self.longitude = entry_data[CONF_LONGITUDE] + latitude = entry_data[CONF_LATITUDE] + longitude = entry_data[CONF_LONGITUDE] if mode == DAYNIGHT: self.coordinator_forecast_legacy = nws_data.coordinator_forecast else: @@ -153,6 +156,8 @@ def __init__( self._forecast_twice_daily: list[dict[str, Any]] | None = None self._attr_unique_id = _calculate_unique_id(entry_data, mode) + self._attr_device_info = device_info(latitude, longitude) + self._attr_name = f"{self.station} {self.mode.title()}" async def async_added_to_hass(self) -> None: """Set up a listener and load data.""" @@ -193,11 +198,6 @@ def _handle_legacy_forecast_coordinator_update(self) -> None: self._forecast_legacy = self.nws.forecast_hourly self.async_write_ha_state() - @property - def name(self) -> str: - """Return the name of the station.""" - return f"{self.station} {self.mode.title()}" - @property def native_temperature(self) -> float | None: """Return the current temperature.""" @@ -205,11 +205,6 @@ def native_temperature(self) -> float | None: return self.observation.get("temperature") return None - @property - def native_temperature_unit(self) -> str: - """Return the current temperature unit.""" - return UnitOfTemperature.CELSIUS - @property def native_pressure(self) -> int | None: """Return the current pressure.""" @@ -217,11 +212,6 @@ def native_pressure(self) -> int | None: return self.observation.get("seaLevelPressure") return None - @property - def native_pressure_unit(self) -> str: - """Return the current pressure unit.""" - return UnitOfPressure.PA - @property def humidity(self) -> float | None: """Return the name of the sensor.""" @@ -236,11 +226,6 @@ def native_wind_speed(self) -> float | None: return self.observation.get("windSpeed") return None - @property - def native_wind_speed_unit(self) -> str: - """Return the current windspeed.""" - return UnitOfSpeed.KILOMETERS_PER_HOUR - @property def wind_bearing(self) -> int | None: """Return the current wind bearing (degrees).""" @@ -267,11 +252,6 @@ def native_visibility(self) -> int | None: return self.observation.get("visibility") return None - @property - def native_visibility_unit(self) -> str: - """Return visibility unit.""" - return UnitOfLength.METERS - def _forecast( self, nws_forecast: list[dict[str, Any]] | None, mode: str ) -> list[Forecast] | None: @@ -377,8 +357,3 @@ async def async_update(self) -> None: def entity_registry_enabled_default(self) -> bool: """Return if the entity should be enabled when first added to the entity registry.""" return self.mode == DAYNIGHT - - @property - def device_info(self) -> DeviceInfo: - """Return device info.""" - return device_info(self.latitude, self.longitude) From a9891e40fd21be7772e5e02f915c55cd34926441 Mon Sep 17 00:00:00 2001 From: James Chaloupka <47349533+SirGoodenough@users.noreply.github.com> Date: Tue, 12 Sep 2023 13:10:32 -0500 Subject: [PATCH 415/640] Update Deprecated Selector Syntax (#99308) --- .../components/automation/blueprints/motion_light.yaml | 5 +++-- .../automation/blueprints/notify_leaving_zone.yaml | 9 ++++++--- .../script/blueprints/confirmable_notification.yaml | 3 ++- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/automation/blueprints/motion_light.yaml b/homeassistant/components/automation/blueprints/motion_light.yaml index 5b389a3fc266c2..8f5d3f957f990a 100644 --- a/homeassistant/components/automation/blueprints/motion_light.yaml +++ b/homeassistant/components/automation/blueprints/motion_light.yaml @@ -9,8 +9,9 @@ blueprint: name: Motion Sensor selector: entity: - domain: binary_sensor - device_class: motion + filter: + device_class: motion + domain: binary_sensor light_target: name: Light selector: diff --git a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml index 0798a051173a67..e1e3bd5b2f693b 100644 --- a/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml +++ b/homeassistant/components/automation/blueprints/notify_leaving_zone.yaml @@ -9,18 +9,21 @@ blueprint: name: Person selector: entity: - domain: person + filter: + domain: person zone_entity: name: Zone selector: entity: - domain: zone + filter: + domain: zone notify_device: name: Device to notify description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app trigger: platform: state diff --git a/homeassistant/components/script/blueprints/confirmable_notification.yaml b/homeassistant/components/script/blueprints/confirmable_notification.yaml index 37e04351d9a98f..c5f42494f02659 100644 --- a/homeassistant/components/script/blueprints/confirmable_notification.yaml +++ b/homeassistant/components/script/blueprints/confirmable_notification.yaml @@ -12,7 +12,8 @@ blueprint: description: Device needs to run the official Home Assistant app to receive notifications. selector: device: - integration: mobile_app + filter: + integration: mobile_app title: name: "Title" description: "The title of the button shown in the notification." From 93f3bc6c2bd591cdcdedcb11f75e5b86c8c36b8d Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Sep 2023 20:11:12 +0200 Subject: [PATCH 416/640] Bump sigstore/cosign-installer from 3.1.1 to 3.1.2 (#99563) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 6ac535647b8343..0694b1b75e0bbd 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -334,7 +334,7 @@ jobs: uses: actions/checkout@v4.0.0 - name: Install Cosign - uses: sigstore/cosign-installer@v3.1.1 + uses: sigstore/cosign-installer@v3.1.2 with: cosign-release: "v2.0.2" From 70c6bceaee828cedf7f5328b2de92828e2dc312f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:12:14 -0500 Subject: [PATCH 417/640] Use short hand entity_registry_enabled_default in nws (#100227) * Use short hand entity_registry_enabled_default in nws see https://github.com/home-assistant/core/pull/95315 * Update homeassistant/components/nws/sensor.py --- homeassistant/components/nws/weather.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/nws/weather.py b/homeassistant/components/nws/weather.py index d68b1fc745c732..9d41e54ccd0c00 100644 --- a/homeassistant/components/nws/weather.py +++ b/homeassistant/components/nws/weather.py @@ -149,6 +149,7 @@ def __init__( self.station = self.nws.station self.mode = mode + self._attr_entity_registry_enabled_default = mode == DAYNIGHT self.observation: dict[str, Any] | None = None self._forecast_hourly: list[dict[str, Any]] | None = None @@ -352,8 +353,3 @@ async def async_update(self) -> None: """ await self.coordinator.async_request_refresh() await self.coordinator_forecast_legacy.async_request_refresh() - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.mode == DAYNIGHT From 9672cdf3a9bf000f2404cf7d5cac92fb0a791ece Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:19:45 +0200 Subject: [PATCH 418/640] Add entity translations to WLED (#99056) --- homeassistant/components/wled/button.py | 1 - homeassistant/components/wled/coordinator.py | 2 +- homeassistant/components/wled/light.py | 4 +- homeassistant/components/wled/select.py | 5 +- homeassistant/components/wled/sensor.py | 20 +++---- homeassistant/components/wled/strings.json | 55 +++++++++++++++++++ homeassistant/components/wled/switch.py | 6 +- homeassistant/components/wled/update.py | 1 - .../wled/snapshots/test_select.ambr | 4 +- .../wled/snapshots/test_switch.ambr | 6 +- tests/components/wled/test_light.py | 46 ++++++++-------- 11 files changed, 101 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/wled/button.py b/homeassistant/components/wled/button.py index 2f9e11627636e3..430ee067486d68 100644 --- a/homeassistant/components/wled/button.py +++ b/homeassistant/components/wled/button.py @@ -28,7 +28,6 @@ class WLEDRestartButton(WLEDEntity, ButtonEntity): _attr_device_class = ButtonDeviceClass.RESTART _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Restart" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the button entity.""" diff --git a/homeassistant/components/wled/coordinator.py b/homeassistant/components/wled/coordinator.py index 9ba3fd2cb3d026..6f3bae03bfa3bc 100644 --- a/homeassistant/components/wled/coordinator.py +++ b/homeassistant/components/wled/coordinator.py @@ -46,7 +46,7 @@ def __init__( @property def has_master_light(self) -> bool: - """Return if the coordinated device has an master light.""" + """Return if the coordinated device has a master light.""" return self.keep_master_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 1eb8074bbc1546..6675118e565756 100644 --- a/homeassistant/components/wled/light.py +++ b/homeassistant/components/wled/light.py @@ -52,7 +52,7 @@ class WLEDMasterLight(WLEDEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_icon = "mdi:led-strip-variant" - _attr_name = "Master" + _attr_translation_key = "main" _attr_supported_features = LightEntityFeature.TRANSITION _attr_supported_color_modes = {ColorMode.BRIGHTNESS} @@ -200,7 +200,7 @@ async def async_turn_off(self, **kwargs: Any) -> None: # 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 + # If there is no master control, and only 1 segment, handle the master if not self.coordinator.has_master_light: await self.coordinator.wled.master(on=False, transition=transition) return diff --git a/homeassistant/components/wled/select.py b/homeassistant/components/wled/select.py index c31f8e1277e372..977c76025ac4fc 100644 --- a/homeassistant/components/wled/select.py +++ b/homeassistant/components/wled/select.py @@ -50,7 +50,6 @@ class WLEDLiveOverrideSelect(WLEDEntity, SelectEntity): _attr_entity_category = EntityCategory.CONFIG _attr_icon = "mdi:theater" - _attr_name = "Live override" _attr_translation_key = "live_override" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: @@ -75,7 +74,7 @@ class WLEDPresetSelect(WLEDEntity, SelectEntity): """Defined a WLED Preset select.""" _attr_icon = "mdi:playlist-play" - _attr_name = "Preset" + _attr_translation_key = "preset" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED .""" @@ -106,7 +105,7 @@ class WLEDPlaylistSelect(WLEDEntity, SelectEntity): """Define a WLED Playlist select.""" _attr_icon = "mdi:play-speed" - _attr_name = "Playlist" + _attr_translation_key = "playlist" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED playlist.""" diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 668b90159b5465..7d1431c093bfef 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -50,7 +50,7 @@ class WLEDSensorEntityDescription( SENSORS: tuple[WLEDSensorEntityDescription, ...] = ( WLEDSensorEntityDescription( key="estimated_current", - name="Estimated current", + translation_key="estimated_current", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, device_class=SensorDeviceClass.CURRENT, state_class=SensorStateClass.MEASUREMENT, @@ -60,13 +60,13 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="info_leds_count", - name="LED count", + translation_key="info_leds_count", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.leds.count, ), WLEDSensorEntityDescription( key="info_leds_max_power", - name="Max current", + translation_key="info_leds_max_power", native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, entity_category=EntityCategory.DIAGNOSTIC, device_class=SensorDeviceClass.CURRENT, @@ -75,7 +75,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="uptime", - name="Uptime", + translation_key="uptime", device_class=SensorDeviceClass.TIMESTAMP, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -83,7 +83,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="free_heap", - name="Free memory", + translation_key="free_heap", icon="mdi:memory", native_unit_of_measurement=UnitOfInformation.BYTES, state_class=SensorStateClass.MEASUREMENT, @@ -94,7 +94,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_signal", - name="Wi-Fi signal", + translation_key="wifi_signal", icon="mdi:wifi", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, @@ -104,7 +104,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_rssi", - name="Wi-Fi RSSI", + translation_key="wifi_rssi", native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, device_class=SensorDeviceClass.SIGNAL_STRENGTH, state_class=SensorStateClass.MEASUREMENT, @@ -114,7 +114,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_channel", - name="Wi-Fi channel", + translation_key="wifi_channel", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -122,7 +122,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="wifi_bssid", - name="Wi-Fi BSSID", + translation_key="wifi_bssid", icon="mdi:wifi", entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, @@ -130,7 +130,7 @@ class WLEDSensorEntityDescription( ), WLEDSensorEntityDescription( key="ip", - name="IP", + translation_key="ip", icon="mdi:ip-network", entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda device: device.info.ip, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 9fc6573b112d25..5791732dfbe296 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -32,13 +32,68 @@ } }, "entity": { + "light": { + "main": { + "name": "Main" + } + }, "select": { "live_override": { + "name": "Live override", "state": { "0": "[%key:common::state::off%]", "1": "[%key:common::state::on%]", "2": "Until device restarts" } + }, + "preset": { + "name": "Preset" + }, + "playlist": { + "name": "Playlist" + } + }, + "sensor": { + "estimated_current": { + "name": "Estimated current" + }, + "info_leds_count": { + "name": "LED count" + }, + "info_leds_max_power": { + "name": "Max current" + }, + "uptime": { + "name": "Uptime" + }, + "free_heap": { + "name": "Free memory" + }, + "wifi_signal": { + "name": "Wi-Fi signal" + }, + "wifi_rssi": { + "name": "Wi-Fi RSSI" + }, + "wifi_channel": { + "name": "Wi-Fi channel" + }, + "wifi_bssid": { + "name": "Wi-Fi BSSID" + }, + "ip": { + "name": "IP" + } + }, + "switch": { + "nightlight": { + "name": "Nightlight" + }, + "sync_send": { + "name": "Sync send" + }, + "sync_receive": { + "name": "Sync receive" } } }, diff --git a/homeassistant/components/wled/switch.py b/homeassistant/components/wled/switch.py index 99b875c16424f1..680684e96dfbfc 100644 --- a/homeassistant/components/wled/switch.py +++ b/homeassistant/components/wled/switch.py @@ -55,7 +55,7 @@ class WLEDNightlightSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:weather-night" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Nightlight" + _attr_translation_key = "nightlight" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED nightlight switch.""" @@ -93,7 +93,7 @@ class WLEDSyncSendSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:upload-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync send" + _attr_translation_key = "sync_send" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync send switch.""" @@ -126,7 +126,7 @@ class WLEDSyncReceiveSwitch(WLEDEntity, SwitchEntity): _attr_icon = "mdi:download-network-outline" _attr_entity_category = EntityCategory.CONFIG - _attr_name = "Sync receive" + _attr_translation_key = "sync_receive" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize WLED sync receive switch.""" diff --git a/homeassistant/components/wled/update.py b/homeassistant/components/wled/update.py index 75546fdac1a64f..954279366beb67 100644 --- a/homeassistant/components/wled/update.py +++ b/homeassistant/components/wled/update.py @@ -36,7 +36,6 @@ class WLEDUpdateEntity(WLEDEntity, UpdateEntity): UpdateEntityFeature.INSTALL | UpdateEntityFeature.SPECIFIC_VERSION ) _attr_title = "WLED" - _attr_name = "Firmware" def __init__(self, coordinator: WLEDDataUpdateCoordinator) -> None: """Initialize the update entity.""" diff --git a/tests/components/wled/snapshots/test_select.ambr b/tests/components/wled/snapshots/test_select.ambr index 05d61fc18cb253..9cfc6c6e3febea 100644 --- a/tests/components/wled/snapshots/test_select.ambr +++ b/tests/components/wled/snapshots/test_select.ambr @@ -310,7 +310,7 @@ 'original_name': 'Playlist', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'playlist', 'unique_id': 'aabbccddee11_playlist', 'unit_of_measurement': None, }) @@ -393,7 +393,7 @@ 'original_name': 'Preset', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'preset', 'unique_id': 'aabbccddee11_preset', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/snapshots/test_switch.ambr b/tests/components/wled/snapshots/test_switch.ambr index f89bde6ee17421..1434d2b2b2d7aa 100644 --- a/tests/components/wled/snapshots/test_switch.ambr +++ b/tests/components/wled/snapshots/test_switch.ambr @@ -40,7 +40,7 @@ 'original_name': 'Nightlight', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'nightlight', 'unique_id': 'aabbccddeeff_nightlight', 'unit_of_measurement': None, }) @@ -189,7 +189,7 @@ 'original_name': 'Sync receive', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_receive', 'unique_id': 'aabbccddeeff_sync_receive', 'unit_of_measurement': None, }) @@ -264,7 +264,7 @@ 'original_name': 'Sync send', 'platform': 'wled', 'supported_features': 0, - 'translation_key': None, + 'translation_key': 'sync_send', 'unique_id': 'aabbccddeeff_sync_send', 'unit_of_measurement': None, }) diff --git a/tests/components/wled/test_light.py b/tests/components/wled/test_light.py index 678b4a44459f8a..ab8330293ba0fb 100644 --- a/tests/components/wled/test_light.py +++ b/tests/components/wled/test_light.py @@ -60,12 +60,12 @@ async def test_rgb_light_state( assert (entry := entity_registry.async_get("light.wled_rgb_light_segment_1")) assert entry.unique_id == "aabbccddeeff_1" - # Test master control of the lightstrip - assert (state := hass.states.get("light.wled_rgb_light_master")) + # Test main control of the lightstrip + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.attributes.get(ATTR_BRIGHTNESS) == 127 assert state.state == STATE_ON - assert (entry := entity_registry.async_get("light.wled_rgb_light_master")) + assert (entry := entity_registry.async_get("light.wled_rgb_light_main")) assert entry.unique_id == "aabbccddeeff" @@ -110,15 +110,15 @@ async def test_segment_change_state( ) -async def test_master_change_state( +async def test_main_change_state( hass: HomeAssistant, mock_wled: MagicMock, ) -> None: - """Test the change of state of the WLED master light control.""" + """Test the change of state of the WLED main light control.""" await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 1 @@ -132,7 +132,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -147,7 +147,7 @@ async def test_master_change_state( await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, - {ATTR_ENTITY_ID: "light.wled_rgb_light_master", ATTR_TRANSITION: 5}, + {ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5}, blocking=True, ) assert mock_wled.master.call_count == 3 @@ -161,7 +161,7 @@ async def test_master_change_state( SERVICE_TURN_ON, { ATTR_BRIGHTNESS: 42, - ATTR_ENTITY_ID: "light.wled_rgb_light_master", + ATTR_ENTITY_ID: "light.wled_rgb_light_main", ATTR_TRANSITION: 5, }, blocking=True, @@ -183,7 +183,7 @@ async def test_dynamically_handle_segments( """Test if a new/deleted segment is dynamically added/removed.""" assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert not hass.states.get("light.wled_rgb_light_segment_1") return_value = mock_wled.update.return_value @@ -195,21 +195,21 @@ async def test_dynamically_handle_segments( async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_ON + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_ON assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) assert segment1.state == STATE_ON - # Test adding if segment shows up again, including the master entity + # Test adding if segment shows up again, including the main entity mock_wled.update.return_value = return_value freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) await hass.async_block_till_done() - assert (master := hass.states.get("light.wled_rgb_light_master")) - assert master.state == STATE_UNAVAILABLE + assert (main := hass.states.get("light.wled_rgb_light_main")) + assert main.state == STATE_UNAVAILABLE assert (segment0 := hass.states.get("light.wled_rgb_light")) assert segment0.state == STATE_ON assert (segment1 := hass.states.get("light.wled_rgb_light_segment_1")) @@ -225,11 +225,11 @@ async def test_single_segment_behavior( """Test the behavior of the integration with a single segment.""" device = mock_wled.update.return_value - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") assert (state := hass.states.get("light.wled_rgb_light")) assert state.state == STATE_ON - # Test segment brightness takes master into account + # Test segment brightness takes main into account device.state.brightness = 100 device.state.segments[0].brightness = 255 freezer.tick(SCAN_INTERVAL) @@ -239,7 +239,7 @@ async def test_single_segment_behavior( assert (state := hass.states.get("light.wled_rgb_light")) assert state.attributes.get(ATTR_BRIGHTNESS) == 100 - # Test segment is off when master is off + # Test segment is off when main is off device.state.on = False freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) @@ -248,7 +248,7 @@ async def test_single_segment_behavior( assert state assert state.state == STATE_OFF - # Test master is turned off when turning off a single segment + # Test main is turned off when turning off a single segment await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_OFF, @@ -261,7 +261,7 @@ async def test_single_segment_behavior( transition=50, ) - # Test master is turned on when turning on a single segment, and segment + # Test main is turned on when turning on a single segment, and segment # brightness is set to 255. await hass.services.async_call( LIGHT_DOMAIN, @@ -346,18 +346,18 @@ async def test_rgbw_light(hass: HomeAssistant, mock_wled: MagicMock) -> None: @pytest.mark.parametrize("device_fixture", ["rgb_single_segment"]) -async def test_single_segment_with_keep_master_light( +async def test_single_segment_with_keep_main_light( hass: HomeAssistant, init_integration: MockConfigEntry, mock_wled: MagicMock, ) -> None: """Test the behavior of the integration with a single segment.""" - assert not hass.states.get("light.wled_rgb_light_master") + assert not hass.states.get("light.wled_rgb_light_main") hass.config_entries.async_update_entry( init_integration, options={CONF_KEEP_MASTER_LIGHT: True} ) await hass.async_block_till_done() - assert (state := hass.states.get("light.wled_rgb_light_master")) + assert (state := hass.states.get("light.wled_rgb_light_main")) assert state.state == STATE_ON From 693a271e4017f91161dce5359dcc5de5e3c57203 Mon Sep 17 00:00:00 2001 From: mkmer Date: Tue, 12 Sep 2023 14:29:47 -0400 Subject: [PATCH 419/640] Clean up device registry for climate devices that no longer exist in Honeywell (#100072) --- homeassistant/components/honeywell/climate.py | 32 +++++++++++ tests/components/honeywell/test_init.py | 55 ++++++++++++++++++- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index d12d90a02c3607..c285ab83bd1111 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -28,6 +28,7 @@ from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -97,6 +98,37 @@ async def async_setup_entry( for device in data.devices.values() ] ) + remove_stale_devices(hass, entry, data.devices) + + +def remove_stale_devices( + hass: HomeAssistant, + config_entry: ConfigEntry, + devices: dict[str, SomeComfortDevice], +) -> None: + """Remove stale devices from device registry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + all_device_ids: list = [] + for device in devices.values(): + all_device_ids.append(device.deviceid) + + for device_entry in device_entries: + device_id: str | None = None + + for identifier in device_entry.identifiers: + device_id = identifier[1] + break + + if device_id is None or device_id not in all_device_ids: + # If device_id is None an invalid device entry was found for this config entry. + # If the device_id is not in existing device ids it's a stale device entry. + # Remove config entry from this device entry in either case. + device_registry.async_update_device( + device_entry.id, remove_config_entry_id=config_entry.entry_id + ) class HoneywellUSThermostat(ClimateEntity): diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index f7629fa958e887..e5afe311295f1f 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -12,6 +12,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr from . import init_integration @@ -33,7 +34,10 @@ async def test_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) - async def test_setup_multiple_thermostats( - hass: HomeAssistant, config_entry: MockConfigEntry, location, another_device + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, ) -> None: """Test that the config form is shown.""" location.devices_by_id[another_device.deviceid] = another_device @@ -50,8 +54,8 @@ async def test_setup_multiple_thermostats_with_same_deviceid( hass: HomeAssistant, caplog: pytest.LogCaptureFixture, config_entry: MockConfigEntry, - device, - client, + device: MagicMock, + client: MagicMock, ) -> None: """Test Honeywell TCC API returning duplicate device IDs.""" mock_location2 = create_autospec(aiosomecomfort.Location, instance=True) @@ -115,3 +119,48 @@ async def test_no_devices( client.locations_by_id = {} await init_integration(hass, config_entry) assert config_entry.state is ConfigEntryState.SETUP_ERROR + + +async def test_remove_stale_device( + hass: HomeAssistant, + config_entry: MockConfigEntry, + location: MagicMock, + another_device: MagicMock, + client: MagicMock, +) -> None: + """Test that the stale device is removed.""" + location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 6 + ) # 2 climate entities; 4 sensor entities + + device_registry = dr.async_get(hass) + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 2 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + + assert await config_entry.async_unload(hass) + await hass.async_block_till_done() + assert config_entry.state == ConfigEntryState.NOT_LOADED + + del location.devices_by_id[another_device.deviceid] + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + assert ( + hass.states.async_entity_ids_count() == 3 + ) # 1 climate entities; 2 sensor entities + + device_entry = dr.async_entries_for_config_entry( + device_registry, config_entry.entry_id + ) + assert len(device_entry) == 1 + assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) From cc252f705fa6b1a28e08a7b3ac667cbf56a46a71 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:34:50 -0500 Subject: [PATCH 420/640] Use short handle attributes for device class in netatmo cover (#100228) --- homeassistant/components/netatmo/cover.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/homeassistant/components/netatmo/cover.py b/homeassistant/components/netatmo/cover.py index 41bf84c8334788..2e4bf9e7d3c86a 100644 --- a/homeassistant/components/netatmo/cover.py +++ b/homeassistant/components/netatmo/cover.py @@ -51,6 +51,7 @@ class NetatmoCover(NetatmoBase, CoverEntity): | CoverEntityFeature.STOP | CoverEntityFeature.SET_POSITION ) + _attr_device_class = CoverDeviceClass.SHUTTER def __init__(self, netatmo_device: NetatmoDevice) -> None: """Initialize the Netatmo device.""" @@ -98,11 +99,6 @@ async def async_set_cover_position(self, **kwargs: Any) -> None: """Move the cover shutter to a specific position.""" await self._cover.async_set_target_position(kwargs[ATTR_POSITION]) - @property - def device_class(self) -> CoverDeviceClass: - """Return the device class.""" - return CoverDeviceClass.SHUTTER - @callback def async_update_callback(self) -> None: """Update the entity's state.""" From 51576b7214e25693309252ffe77b20d1c682679a Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 20:41:26 +0200 Subject: [PATCH 421/640] Improve typing of entity.entity_sources (#99407) * Improve typing of entity.entity_sources * Calculate entity info source when generating WS response * Adjust typing * Update tests --- homeassistant/components/alexa/entities.py | 3 +- .../components/recorder/db_schema.py | 3 +- homeassistant/components/search/__init__.py | 7 ++-- homeassistant/components/sensor/recorder.py | 11 ++++--- .../components/websocket_api/commands.py | 2 +- homeassistant/helpers/entity.py | 32 +++++++++++++------ tests/components/search/test_init.py | 5 --- tests/helpers/test_entity.py | 2 -- 8 files changed, 39 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/alexa/entities.py b/homeassistant/components/alexa/entities.py index 7f6331515c66b5..da0bd8b36aaa9d 100644 --- a/homeassistant/components/alexa/entities.py +++ b/homeassistant/components/alexa/entities.py @@ -707,7 +707,8 @@ def interfaces(self) -> Generator[AlexaCapability, None, None]: # AlexaEqualizerController is disabled for denonavr # since it blocks alexa from discovering any devices. - domain = entity_sources(self.hass).get(self.entity_id, {}).get("domain") + entity_info = entity_sources(self.hass).get(self.entity_id) + domain = entity_info["domain"] if entity_info else None if ( supported & media_player.MediaPlayerEntityFeature.SELECT_SOUND_MODE and domain != "denonavr" diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index 508874c54e56f0..e25c6d6dd5fbc3 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -40,6 +40,7 @@ MAX_LENGTH_STATE_STATE, ) from homeassistant.core import Context, Event, EventOrigin, State, split_entity_id +from homeassistant.helpers.entity import EntityInfo from homeassistant.helpers.json import JSON_DUMP, json_bytes, json_bytes_strip_null import homeassistant.util.dt as dt_util from homeassistant.util.json import ( @@ -558,7 +559,7 @@ def __repr__(self) -> str: @staticmethod def shared_attrs_bytes_from_event( event: Event, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], exclude_attrs_by_domain: dict[str, set[str]], dialect: SupportedDialect | None, ) -> bytes: diff --git a/homeassistant/components/search/__init__.py b/homeassistant/components/search/__init__.py index 69796800e61562..ac9a13850d6329 100644 --- a/homeassistant/components/search/__init__.py +++ b/homeassistant/components/search/__init__.py @@ -15,7 +15,10 @@ device_registry as dr, entity_registry as er, ) -from homeassistant.helpers.entity import entity_sources as get_entity_sources +from homeassistant.helpers.entity import ( + EntityInfo, + entity_sources as get_entity_sources, +) from homeassistant.helpers.typing import ConfigType DOMAIN = "search" @@ -97,7 +100,7 @@ def __init__( hass: HomeAssistant, device_reg: dr.DeviceRegistry, entity_reg: er.EntityRegistry, - entity_sources: dict[str, dict[str, str]], + entity_sources: dict[str, EntityInfo], ) -> None: """Search results.""" self.hass = hass diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index e5a35187c99d74..63096b16cd86b4 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -262,8 +262,9 @@ def _normalize_states( def _suggest_report_issue(hass: HomeAssistant, entity_id: str) -> str: """Suggest to report an issue.""" - domain = entity_sources(hass).get(entity_id, {}).get("domain") - custom_component = entity_sources(hass).get(entity_id, {}).get("custom_component") + 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." @@ -296,7 +297,8 @@ def warn_dip( hass.data[WARN_DIP] = set() if entity_id not in hass.data[WARN_DIP]: hass.data[WARN_DIP].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None if domain in ["energy", "growatt_server", "solaredge"]: return _LOGGER.warning( @@ -320,7 +322,8 @@ def warn_negative(hass: HomeAssistant, entity_id: str, state: State) -> None: hass.data[WARN_NEGATIVE] = set() if entity_id not in hass.data[WARN_NEGATIVE]: hass.data[WARN_NEGATIVE].add(entity_id) - domain = entity_sources(hass).get(entity_id, {}).get("domain") + entity_info = entity_sources(hass).get(entity_id) + domain = entity_info["domain"] if entity_info else None _LOGGER.warning( ( "Entity %s %shas state class total_increasing, but its state is " diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 66866197081daf..e140fef861e05c 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -596,7 +596,7 @@ def _template_listener( def _serialize_entity_sources( - entity_infos: dict[str, dict[str, str]] + entity_infos: dict[str, entity.EntityInfo] ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" result = {} diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 7bd510b6fa13d2..99c71e2cc86755 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -12,7 +12,16 @@ import math import sys from timeit import default_timer as timer -from typing import TYPE_CHECKING, Any, Final, Literal, TypeVar, final +from typing import ( + TYPE_CHECKING, + Any, + Final, + Literal, + NotRequired, + TypedDict, + TypeVar, + final, +) import voluptuous as vol @@ -60,8 +69,6 @@ _LOGGER = logging.getLogger(__name__) SLOW_UPDATE_WARNING = 10 DATA_ENTITY_SOURCE = "entity_info" -SOURCE_CONFIG_ENTRY = "config_entry" -SOURCE_PLATFORM_CONFIG = "platform_config" # Used when converting float states to string: limit precision according to machine # epsilon to make the string representation readable @@ -76,9 +83,9 @@ def async_setup(hass: HomeAssistant) -> None: @callback @bind_hass -def entity_sources(hass: HomeAssistant) -> dict[str, dict[str, str]]: +def entity_sources(hass: HomeAssistant) -> dict[str, EntityInfo]: """Get the entity sources.""" - _entity_sources: dict[str, dict[str, str]] = hass.data[DATA_ENTITY_SOURCE] + _entity_sources: dict[str, EntityInfo] = hass.data[DATA_ENTITY_SOURCE] return _entity_sources @@ -181,6 +188,14 @@ def get_unit_of_measurement(hass: HomeAssistant, entity_id: str) -> str | None: ENTITY_CATEGORIES_SCHEMA: Final = vol.Coerce(EntityCategory) +class EntityInfo(TypedDict): + """Entity info.""" + + domain: str + custom_component: bool + config_entry: NotRequired[str] + + class EntityPlatformState(Enum): """The platform state of an entity.""" @@ -1061,18 +1076,15 @@ async def async_internal_added_to_hass(self) -> None: Not to be extended by integrations. """ - info = { + info: EntityInfo = { "domain": self.platform.platform_name, "custom_component": "custom_components" in type(self).__module__, } if self.platform.config_entry: - info["source"] = SOURCE_CONFIG_ENTRY info["config_entry"] = self.platform.config_entry.entry_id - else: - info["source"] = SOURCE_PLATFORM_CONFIG - self.hass.data[DATA_ENTITY_SOURCE][self.entity_id] = info + entity_sources(self.hass)[self.entity_id] = info if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests diff --git a/tests/components/search/test_init.py b/tests/components/search/test_init.py index 40ec9c22afe6b8..ebf70a6239c6c5 100644 --- a/tests/components/search/test_init.py +++ b/tests/components/search/test_init.py @@ -6,7 +6,6 @@ from homeassistant.helpers import ( area_registry as ar, device_registry as dr, - entity, entity_registry as er, ) from homeassistant.setup import async_setup_component @@ -22,11 +21,9 @@ def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None: MOCK_ENTITY_SOURCES = { "light.platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": "config_entry_id", "domain": "wled", }, @@ -73,11 +70,9 @@ async def test_search( entity_sources = { "light.wled_platform_config_source": { - "source": entity.SOURCE_PLATFORM_CONFIG, "domain": "wled", }, "light.wled_config_entry_source": { - "source": entity.SOURCE_CONFIG_ENTRY, "config_entry": wled_config_entry.entry_id, "domain": "wled", }, diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 20bea6a98eb172..68eed5b6e3244c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -795,13 +795,11 @@ async def test_setup_source(hass: HomeAssistant) -> None: "test_domain.platform_config_source": { "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_PLATFORM_CONFIG, }, "test_domain.config_entry_source": { "config_entry": platform.config_entry.entry_id, "custom_component": False, "domain": "test_platform", - "source": entity.SOURCE_CONFIG_ENTRY, }, } From 5fcb69e004e162e69c34a3974fcff75d4e0bc5d7 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 20:46:43 +0200 Subject: [PATCH 422/640] Use shorthanded attributes for MQTT cover (#100230) --- homeassistant/components/mqtt/cover.py | 45 +++++++++++--------------- 1 file changed, 19 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c11cf2dfb85d6e..ae22eb675ac215 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -13,7 +13,6 @@ ATTR_POSITION, ATTR_TILT_POSITION, DEVICE_CLASSES_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -335,6 +334,25 @@ def _setup_from_config(self, config: ConfigType) -> None: config_attributes=template_config_attributes, ).async_render_with_possible_json_value + self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + + supported_features = CoverEntityFeature(0) + if self._config.get(CONF_COMMAND_TOPIC) is not None: + if self._config.get(CONF_PAYLOAD_OPEN) is not None: + supported_features |= CoverEntityFeature.OPEN + if self._config.get(CONF_PAYLOAD_CLOSE) is not None: + supported_features |= CoverEntityFeature.CLOSE + if self._config.get(CONF_PAYLOAD_STOP) is not None: + supported_features |= CoverEntityFeature.STOP + + if self._config.get(CONF_SET_POSITION_TOPIC) is not None: + supported_features |= CoverEntityFeature.SET_POSITION + + if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: + supported_features |= TILT_FEATURES + + self._attr_supported_features = supported_features + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" topics = {} @@ -506,31 +524,6 @@ def current_cover_tilt_position(self) -> int | None: """Return current position of cover tilt.""" return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the class of this sensor.""" - return self._config.get(CONF_DEVICE_CLASS) - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature(0) - if self._config.get(CONF_COMMAND_TOPIC) is not None: - if self._config.get(CONF_PAYLOAD_OPEN) is not None: - supported_features |= CoverEntityFeature.OPEN - if self._config.get(CONF_PAYLOAD_CLOSE) is not None: - supported_features |= CoverEntityFeature.CLOSE - if self._config.get(CONF_PAYLOAD_STOP) is not None: - supported_features |= CoverEntityFeature.STOP - - if self._config.get(CONF_SET_POSITION_TOPIC) is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._config.get(CONF_TILT_COMMAND_TOPIC) is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up. From 09ad1a9a3685e885fc868e0e111df2856d6f2c84 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 20:47:48 +0200 Subject: [PATCH 423/640] Remove unnecessary block use of pylint disable in components p-z (#100192) --- homeassistant/components/sensor/__init__.py | 2 +- homeassistant/components/shelly/entity.py | 4 ++-- homeassistant/helpers/script.py | 9 ++++----- homeassistant/helpers/service.py | 2 +- homeassistant/helpers/template.py | 2 +- homeassistant/runner.py | 4 ++-- homeassistant/scripts/check_config.py | 7 +++---- 7 files changed, 14 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index b8151256519f9a..6b4e4a17fc2261 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -13,7 +13,7 @@ from homeassistant.config_entries import ConfigEntry -# pylint: disable=[hass-deprecated-import] +# pylint: disable-next=hass-deprecated-import from homeassistant.const import ( # noqa: F401 ATTR_UNIT_OF_MEASUREMENT, CONF_UNIT_OF_MEASUREMENT, diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 1dc7573b738294..69dc6cb934011a 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -551,7 +551,7 @@ def available(self) -> bool: class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity): """Represent a shelly sleeping block attribute entity.""" - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyBlockCoordinator, @@ -625,7 +625,7 @@ class ShellySleepingRpcAttributeEntity(ShellyRpcAttributeEntity): entity_description: RpcEntityDescription - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__( self, coordinator: ShellyRpcCoordinator, diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index c9d8de23b96cec..a1d045eb542d39 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -911,7 +911,7 @@ async def async_run_sequence(iteration, extra_msg=""): async def _async_choose_step(self) -> None: """Choose a sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access choose_data = await self._script._async_get_choose_data(self._step) with trace_path("choose"): @@ -933,7 +933,7 @@ async def _async_choose_step(self) -> None: async def _async_if_step(self) -> None: """If sequence.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access if_data = await self._script._async_get_if_data(self._step) test_conditions = False @@ -1047,7 +1047,7 @@ async def _async_stop_step(self): @async_trace_path("parallel") async def _async_parallel_step(self) -> None: """Run a sequence in parallel.""" - # pylint: disable=protected-access + # pylint: disable-next=protected-access scripts = await self._script._async_get_parallel_scripts(self._step) async def async_run_with_trace(idx: int, script: Script) -> None: @@ -1107,9 +1107,8 @@ async def async_run(self) -> None: await super().async_run() def _finish(self) -> None: - # pylint: disable=protected-access if self.lock_acquired: - self._script._queue_lck.release() + self._script._queue_lck.release() # pylint: disable=protected-access self.lock_acquired = False super()._finish() diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index a0fe24cb6564a5..4532e1a00ae702 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -73,7 +73,7 @@ @cache def _base_components() -> dict[str, ModuleType]: """Return a cached lookup of base components.""" - # pylint: disable=import-outside-toplevel + # pylint: disable-next=import-outside-toplevel from homeassistant.components import ( alarm_control_panel, calendar, diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 9f280db6c98382..070e5b6d9ad41e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -937,7 +937,7 @@ class TemplateStateBase(State): __delitem__ = _readonly # Inheritance is done so functions that check against State keep working - # pylint: disable=super-init-not-called + # pylint: disable-next=super-init-not-called def __init__(self, hass: HomeAssistant, collect: bool, entity_id: str) -> None: """Initialize template state.""" self._hass = hass diff --git a/homeassistant/runner.py b/homeassistant/runner.py index ed49db37f97147..10521f8013501a 100644 --- a/homeassistant/runner.py +++ b/homeassistant/runner.py @@ -163,8 +163,7 @@ async def setup_and_run_hass(runtime_config: RuntimeConfig) -> int: def _enable_posix_spawn() -> None: """Enable posix_spawn on Alpine Linux.""" - # pylint: disable=protected-access - if subprocess._USE_POSIX_SPAWN: + if subprocess._USE_POSIX_SPAWN: # pylint: disable=protected-access return # The subprocess module does not know about Alpine Linux/musl @@ -172,6 +171,7 @@ def _enable_posix_spawn() -> None: # less efficient. This is a workaround to force posix_spawn() # when using musl since cpython is not aware its supported. tag = next(packaging.tags.sys_tags()) + # pylint: disable-next=protected-access subprocess._USE_POSIX_SPAWN = "musllinux" in tag.platform diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 38fa9cc2463f29..9a63c73590b5c2 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -30,7 +30,6 @@ REQUIREMENTS = ("colorlog==6.7.0",) _LOGGER = logging.getLogger(__name__) -# pylint: disable=protected-access MOCKS: dict[str, tuple[str, Callable]] = { "load": ("homeassistant.util.yaml.loader.load_yaml", yaml_loader.load_yaml), "load*": ("homeassistant.config.load_yaml", yaml_loader.load_yaml), @@ -166,13 +165,13 @@ def check(config_dir, secrets=False): "secret_cache": {}, } - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_load(filename, secrets=None): """Mock hass.util.load_yaml to save config file names.""" res["yaml_files"][filename] = True return MOCKS["load"][1](filename, secrets) - # pylint: disable=possibly-unused-variable + # pylint: disable-next=possibly-unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" try: @@ -201,7 +200,7 @@ def mock_secrets(ldr, node): def secrets_proxy(*args): secrets = Secrets(*args) - res["secret_cache"] = secrets._cache + res["secret_cache"] = secrets._cache # pylint: disable=protected-access return secrets try: From 8fe5a5a398be6720377b2a6c6e755b947af06c3c Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 20:48:47 +0200 Subject: [PATCH 424/640] Introduce base class for Trafikverket camera (#100114) * Introduce base class for Trafikverket camera * fix feedback * Fix feedback --- .../trafikverket_camera/binary_sensor.py | 34 +---------- .../components/trafikverket_camera/camera.py | 18 +----- .../components/trafikverket_camera/entity.py | 56 +++++++++++++++++++ .../components/trafikverket_camera/sensor.py | 36 +----------- 4 files changed, 65 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/trafikverket_camera/entity.py diff --git a/homeassistant/components/trafikverket_camera/binary_sensor.py b/homeassistant/components/trafikverket_camera/binary_sensor.py index bfbecf707bffeb..c9da5bd5d8ab9b 100644 --- a/homeassistant/components/trafikverket_camera/binary_sensor.py +++ b/homeassistant/components/trafikverket_camera/binary_sensor.py @@ -10,12 +10,11 @@ ) from homeassistant.config_entries import ConfigEntry 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 from .const import DOMAIN from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -51,47 +50,20 @@ async def async_setup_entry( async_add_entities( [ TrafikverketCameraBinarySensor( - coordinator, entry.entry_id, entry.title, BINARY_SENSOR_TYPE + coordinator, entry.entry_id, BINARY_SENSOR_TYPE ) ] ) class TrafikverketCameraBinarySensor( - CoordinatorEntity[TVDataUpdateCoordinator], BinarySensorEntity + TrafikverketCameraNonCameraEntity, BinarySensorEntity ): """Representation of a Trafikverket Camera binary sensor.""" entity_description: TVCameraSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: TVDataUpdateCoordinator, - entry_id: str, - name: str, - entity_description: TVCameraSensorEntityDescription, - ) -> None: - """Initiate Trafikverket Camera Binary sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id = f"{entry_id}-{entity_description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) - self._update_attr() @callback def _update_attr(self) -> None: """Update _attr.""" self._attr_is_on = self.entity_description.value_fn(self.coordinator.data) - - @callback - def _handle_coordinator_update(self) -> None: - self._update_attr() - return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index 936e460638f245..a7da3db1433f4d 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -8,12 +8,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_LOCATION from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import ATTR_DESCRIPTION, ATTR_TYPE, DOMAIN from .coordinator import TVDataUpdateCoordinator +from .entity import TrafikverketCameraEntity async def async_setup_entry( @@ -29,17 +28,15 @@ async def async_setup_entry( [ TVCamera( coordinator, - entry.title, entry.entry_id, ) ], ) -class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): +class TVCamera(TrafikverketCameraEntity, Camera): """Implement Trafikverket camera.""" - _attr_has_entity_name = True _attr_name = None _attr_translation_key = "tv_camera" coordinator: TVDataUpdateCoordinator @@ -47,21 +44,12 @@ class TVCamera(CoordinatorEntity[TVDataUpdateCoordinator], Camera): def __init__( self, coordinator: TVDataUpdateCoordinator, - name: str, entry_id: str, ) -> None: """Initialize the camera.""" - super().__init__(coordinator) + super().__init__(coordinator, entry_id) Camera.__init__(self) self._attr_unique_id = entry_id - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) async def async_camera_image( self, width: int | None = None, height: int | None = None diff --git a/homeassistant/components/trafikverket_camera/entity.py b/homeassistant/components/trafikverket_camera/entity.py new file mode 100644 index 00000000000000..ec1d4d8f76bfe8 --- /dev/null +++ b/homeassistant/components/trafikverket_camera/entity.py @@ -0,0 +1,56 @@ +"""Base entity for Trafikverket Camera.""" +from __future__ import annotations + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TVDataUpdateCoordinator + + +class TrafikverketCameraEntity(CoordinatorEntity[TVDataUpdateCoordinator]): + """Base entity for Trafikverket Camera.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator) + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, entry_id)}, + entry_type=DeviceEntryType.SERVICE, + manufacturer="Trafikverket", + model="v1.0", + configuration_url="https://api.trafikinfo.trafikverket.se/", + ) + + +class TrafikverketCameraNonCameraEntity(TrafikverketCameraEntity): + """Base entity for Trafikverket Camera but for non camera entities.""" + + def __init__( + self, + coordinator: TVDataUpdateCoordinator, + entry_id: str, + description: EntityDescription, + ) -> None: + """Initiate Trafikverket Camera Sensor.""" + super().__init__(coordinator, entry_id) + self._attr_unique_id = f"{entry_id}-{description.key}" + self.entity_description = description + self._update_attr() + + @callback + def _update_attr(self) -> None: + """Update _attr.""" + + @callback + def _handle_coordinator_update(self) -> None: + self._update_attr() + return super()._handle_coordinator_update() diff --git a/homeassistant/components/trafikverket_camera/sensor.py b/homeassistant/components/trafikverket_camera/sensor.py index eee2f353de529a..96231bba755733 100644 --- a/homeassistant/components/trafikverket_camera/sensor.py +++ b/homeassistant/components/trafikverket_camera/sensor.py @@ -13,13 +13,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import DEGREE from homeassistant.core import HomeAssistant, callback -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 .const import DOMAIN from .coordinator import CameraData, TVDataUpdateCoordinator +from .entity import TrafikverketCameraNonCameraEntity PARALLEL_UPDATES = 0 @@ -92,39 +91,15 @@ async def async_setup_entry( coordinator: TVDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] async_add_entities( - TrafikverketCameraSensor(coordinator, entry.entry_id, entry.title, description) + TrafikverketCameraSensor(coordinator, entry.entry_id, description) for description in SENSOR_TYPES ) -class TrafikverketCameraSensor( - CoordinatorEntity[TVDataUpdateCoordinator], SensorEntity -): +class TrafikverketCameraSensor(TrafikverketCameraNonCameraEntity, SensorEntity): """Representation of a Trafikverket Camera Sensor.""" entity_description: TVCameraSensorEntityDescription - _attr_has_entity_name = True - - def __init__( - self, - coordinator: TVDataUpdateCoordinator, - entry_id: str, - name: str, - entity_description: TVCameraSensorEntityDescription, - ) -> None: - """Initiate Trafikverket Camera Sensor.""" - super().__init__(coordinator) - self.entity_description = entity_description - self._attr_unique_id = f"{entry_id}-{entity_description.key}" - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, entry_id)}, - manufacturer="Trafikverket", - model="v1.0", - name=name, - configuration_url="https://api.trafikinfo.trafikverket.se/", - ) - self._update_attr() @callback def _update_attr(self) -> None: @@ -132,8 +107,3 @@ def _update_attr(self) -> None: self._attr_native_value = self.entity_description.value_fn( self.coordinator.data ) - - @callback - def _handle_coordinator_update(self) -> None: - self._update_attr() - return super()._handle_coordinator_update() From 5e2bf2b0159ada96303978718320c0722bc52f97 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 13:57:57 -0500 Subject: [PATCH 425/640] Set dynalite cover device class in constructor (#100232) --- homeassistant/components/dynalite/cover.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/dynalite/cover.py b/homeassistant/components/dynalite/cover.py index 96a1f41f9e3a2c..2bac51e0b8b67f 100644 --- a/homeassistant/components/dynalite/cover.py +++ b/homeassistant/components/dynalite/cover.py @@ -12,6 +12,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util.enum import try_parse_enum +from .bridge import DynaliteBridge from .dynalitebase import DynaliteBase, async_setup_entry_base @@ -23,7 +24,7 @@ async def async_setup_entry( """Record the async_add_entities function to add them later when received from Dynalite.""" @callback - def cover_from_device(device, bridge): + def cover_from_device(device: Any, bridge: DynaliteBridge) -> CoverEntity: if device.has_tilt: return DynaliteCoverWithTilt(device, bridge) return DynaliteCover(device, bridge) @@ -36,11 +37,11 @@ def cover_from_device(device, bridge): class DynaliteCover(DynaliteBase, CoverEntity): """Representation of a Dynalite Channel as a Home Assistant Cover.""" - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" + def __init__(self, device: Any, bridge: DynaliteBridge) -> None: + """Initialize the cover.""" + super().__init__(device, bridge) device_class = try_parse_enum(CoverDeviceClass, self._device.device_class) - return device_class or CoverDeviceClass.SHUTTER + self._attr_device_class = device_class or CoverDeviceClass.SHUTTER @property def current_cover_position(self) -> int: From 76c569c62d690db26eea27f460fdbf167e330fa7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 21:00:05 +0200 Subject: [PATCH 426/640] Clean up variables in Soundtouch (#99859) --- .../components/soundtouch/media_player.py | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/soundtouch/media_player.py b/homeassistant/components/soundtouch/media_player.py index 63e5a551745208..fa5c0dd70950a6 100644 --- a/homeassistant/components/soundtouch/media_player.py +++ b/homeassistant/components/soundtouch/media_player.py @@ -78,21 +78,25 @@ class SoundTouchMediaPlayer(MediaPlayerEntity): _attr_device_class = MediaPlayerDeviceClass.SPEAKER _attr_has_entity_name = True _attr_name = None + _attr_source_list = [ + Source.AUX.value, + Source.BLUETOOTH.value, + ] def __init__(self, device: SoundTouchDevice) -> None: """Create SoundTouch media player entity.""" self._device = device - self._attr_unique_id = self._device.config.device_id + self._attr_unique_id = device.config.device_id self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, self._device.config.device_id)}, + identifiers={(DOMAIN, device.config.device_id)}, connections={ - (CONNECTION_NETWORK_MAC, format_mac(self._device.config.mac_address)) + (CONNECTION_NETWORK_MAC, format_mac(device.config.mac_address)) }, manufacturer="Bose Corporation", - model=self._device.config.type, - name=self._device.config.name, + model=device.config.type, + name=device.config.name, ) self._status = None @@ -131,14 +135,6 @@ def source(self): """Name of the current input source.""" return self._status.source - @property - def source_list(self): - """List of available input sources.""" - return [ - Source.AUX.value, - Source.BLUETOOTH.value, - ] - @property def is_volume_muted(self): """Boolean if volume is currently muted.""" From bbcbb2e3222ee0a489b41d189473506f36fdcc12 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 12 Sep 2023 21:07:32 +0200 Subject: [PATCH 427/640] Improve Entity._suggest_report_issue (#100204) --- homeassistant/helpers/entity.py | 22 ++++++++++- tests/components/sensor/test_init.py | 2 +- tests/helpers/test_entity.py | 58 +++++++++++++++++++++++++++- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 99c71e2cc86755..5ed16408388545 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -4,6 +4,7 @@ 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 @@ -49,7 +50,11 @@ InvalidStateError, NoEntitySpecifiedError, ) -from homeassistant.loader import bind_hass +from homeassistant.loader import ( + IntegrationNotLoaded, + async_get_loaded_integration, + bind_hass, +) from homeassistant.util import ensure_unique_string, slugify from . import device_registry as dr, entity_registry as er @@ -1215,8 +1220,21 @@ async def async_request_call(self, coro: Coroutine[Any, Any, _T]) -> _T: 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__: - report_issue = "report it to the custom integration author." + 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 " diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 1f836ad909555a..b7682eb2ec2a89 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -164,7 +164,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 custom integration author" ) in caplog.text state = hass.states.get("sensor.test") diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 68eed5b6e3244c..61ee38a66a7a96 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -27,8 +27,10 @@ MockConfigEntry, MockEntity, MockEntityPlatform, + MockModule, MockPlatform, get_test_home_assistant, + mock_integration, mock_registry, ) @@ -776,7 +778,7 @@ class CustomComponentEntity(entity.Entity): assert ( "Updating state for comp_test.test_entity " "(.CustomComponentEntity'>) " - "took 10.000 seconds. Please report it to the custom integration author." + "took 10.000 seconds. Please report it to the custom integration author" ) in caplog.text @@ -1503,3 +1505,57 @@ async def test_invalid_state( ent._attr_state = "x" * 255 ent.async_write_ha_state() assert hass.states.get("test.test").state == "x" * 255 + + +async def test_suggest_report_issue_built_in( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a built-in integration.""" + mock_entity = entity.Entity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue" + ) + + mock_integration(hass, MockModule(domain="test"), built_in=True) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == ( + "create a bug report at https://github.com/home-assistant/core/issues" + "?q=is%3Aopen+is%3Aissue+label%3A%22integration%3A+test%22" + ) + + +async def test_suggest_report_issue_custom_component( + hass: HomeAssistant, caplog: pytest.LogCaptureFixture +) -> None: + """Test _suggest_report_issue for an entity from a custom component.""" + + class CustomComponentEntity(entity.Entity): + """Custom component entity.""" + + __module__ = "custom_components.bla.sensor" + + mock_entity = CustomComponentEntity() + mock_entity.entity_id = "comp_test.test_entity" + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "report it to the custom integration author" + + mock_integration( + hass, + MockModule( + domain="test", partial_manifest={"issue_tracker": "httpts://some_url"} + ), + built_in=False, + ) + platform = MockEntityPlatform(hass, domain="comp_test", platform_name="test") + await platform.async_add_entities([mock_entity]) + + suggestion = mock_entity._suggest_report_issue() + assert suggestion == "create a bug report at httpts://some_url" From 368a1a944a5081130a0c95e4898631da32fddaee Mon Sep 17 00:00:00 2001 From: Allen Porter Date: Tue, 12 Sep 2023 12:08:13 -0700 Subject: [PATCH 428/640] Remove the uniqueid from todoist (#100206) --- homeassistant/components/todoist/config_flow.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/todoist/config_flow.py b/homeassistant/components/todoist/config_flow.py index 0a41ecb0463d9a..6098df40ea047f 100644 --- a/homeassistant/components/todoist/config_flow.py +++ b/homeassistant/components/todoist/config_flow.py @@ -51,8 +51,6 @@ async def async_step_user( _LOGGER.exception("Unexpected exception") errors["base"] = "unknown" else: - await self.async_set_unique_id(user_input[CONF_TOKEN]) - self._abort_if_unique_id_configured() return self.async_create_entry(title="Todoist", data=user_input) return self.async_show_form( From d417a27c85d36c52e8c8fe05e4e2816e5a18594b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Wed, 13 Sep 2023 04:08:58 +0900 Subject: [PATCH 429/640] Add meteoclimatic sensor statistics (#100186) --- homeassistant/components/meteoclimatic/sensor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/meteoclimatic/sensor.py b/homeassistant/components/meteoclimatic/sensor.py index ed37c6d98eae95..9a54e766945b80 100644 --- a/homeassistant/components/meteoclimatic/sensor.py +++ b/homeassistant/components/meteoclimatic/sensor.py @@ -3,6 +3,7 @@ SensorDeviceClass, SensorEntity, SensorEntityDescription, + SensorStateClass, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -29,6 +30,7 @@ name="Temperature", native_unit_of_measurement=UnitOfTemperature.CELSIUS, device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="temp_max", @@ -47,6 +49,7 @@ name="Humidity", native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="humidity_max", @@ -65,6 +68,7 @@ name="Pressure", native_unit_of_measurement=UnitOfPressure.HPA, device_class=SensorDeviceClass.PRESSURE, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="pressure_max", @@ -83,6 +87,7 @@ name="Wind Speed", native_unit_of_measurement=UnitOfSpeed.KILOMETERS_PER_HOUR, device_class=SensorDeviceClass.WIND_SPEED, + state_class=SensorStateClass.MEASUREMENT, ), SensorEntityDescription( key="wind_max", From f9ce315d1b1cf94d5dd1738d131d47fd3d7bba09 Mon Sep 17 00:00:00 2001 From: Tom Harris Date: Tue, 12 Sep 2023 19:10:40 +0000 Subject: [PATCH 430/640] Support for Insteon 4 button KeypadLink device (#100132) --- homeassistant/components/insteon/api/properties.py | 12 ++++++++++-- homeassistant/components/insteon/ipdb.py | 4 ++++ homeassistant/components/insteon/light.py | 8 ++++++++ homeassistant/components/insteon/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 6 files changed, 28 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/insteon/api/properties.py b/homeassistant/components/insteon/api/properties.py index 7350ab1474347d..80a76e482e51de 100644 --- a/homeassistant/components/insteon/api/properties.py +++ b/homeassistant/components/insteon/api/properties.py @@ -3,7 +3,12 @@ from typing import Any from pyinsteon import devices -from pyinsteon.config import RADIO_BUTTON_GROUPS, RAMP_RATE_IN_SEC, get_usable_value +from pyinsteon.config import ( + LOAD_BUTTON, + RADIO_BUTTON_GROUPS, + RAMP_RATE_IN_SEC, + get_usable_value, +) from pyinsteon.constants import ( RAMP_RATES_SEC, PropertyType, @@ -75,8 +80,11 @@ def get_schema(prop, name, groups): if name == RAMP_RATE_IN_SEC: return _list_schema(name, RAMP_RATE_LIST) if name == RADIO_BUTTON_GROUPS: - button_list = {str(group): groups[group].name for group in groups if group != 1} + button_list = {str(group): groups[group].name for group in groups} return _multi_select_schema(name, button_list) + if name == LOAD_BUTTON: + button_list = {group: groups[group].name for group in groups} + return _list_schema(name, button_list) if prop.value_type == bool: return _bool_schema(name) if prop.value_type == int: diff --git a/homeassistant/components/insteon/ipdb.py b/homeassistant/components/insteon/ipdb.py index de3ba7d55f20fc..9e9f987d6110f7 100644 --- a/homeassistant/components/insteon/ipdb.py +++ b/homeassistant/components/insteon/ipdb.py @@ -10,6 +10,7 @@ DimmableLightingControl_Dial, DimmableLightingControl_DinRail, DimmableLightingControl_FanLinc, + DimmableLightingControl_I3_KeypadLinc_4, DimmableLightingControl_InLineLinc01, DimmableLightingControl_InLineLinc02, DimmableLightingControl_KeypadLinc_6, @@ -55,6 +56,9 @@ DimmableLightingControl_FanLinc: {Platform.LIGHT: [1], Platform.FAN: [2]}, DimmableLightingControl_InLineLinc01: {Platform.LIGHT: [1]}, DimmableLightingControl_InLineLinc02: {Platform.LIGHT: [1]}, + DimmableLightingControl_I3_KeypadLinc_4: { + Platform.LIGHT: [1, 2, 3, 4], + }, DimmableLightingControl_KeypadLinc_6: { Platform.LIGHT: [1], Platform.SWITCH: [3, 4, 5, 6], diff --git a/homeassistant/components/insteon/light.py b/homeassistant/components/insteon/light.py index 1c12bc794f9b48..121d8d62c66aec 100644 --- a/homeassistant/components/insteon/light.py +++ b/homeassistant/components/insteon/light.py @@ -2,6 +2,7 @@ from typing import Any from pyinsteon.config import ON_LEVEL +from pyinsteon.device_types.device_base import Device as InsteonDevice from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry @@ -51,6 +52,13 @@ class InsteonDimmerEntity(InsteonEntity, LightEntity): _attr_color_mode = ColorMode.BRIGHTNESS _attr_supported_color_modes = {ColorMode.BRIGHTNESS} + def __init__(self, device: InsteonDevice, group: int) -> None: + """Init the InsteonDimmerEntity entity.""" + super().__init__(device=device, group=group) + if not self._insteon_device_group.is_dimmable: + self._attr_color_mode = ColorMode.ONOFF + self._attr_supported_color_modes = {ColorMode.ONOFF} + @property def brightness(self): """Return the brightness of this light between 0..255.""" diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index ad3fb7bfbe81fd..5fa45a16fb603b 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -17,8 +17,8 @@ "iot_class": "local_push", "loggers": ["pyinsteon", "pypubsub"], "requirements": [ - "pyinsteon==1.4.3", - "insteon-frontend-home-assistant==0.3.5" + "pyinsteon==1.5.1", + "insteon-frontend-home-assistant==0.4.0" ], "usb": [ { diff --git a/requirements_all.txt b/requirements_all.txt index 9b3192459aaa67..a1a3a598568b7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1064,7 +1064,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1750,7 +1750,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.intesishome pyintesishome==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 656658f254fc78..4d366125d9f014 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -832,7 +832,7 @@ influxdb==5.3.1 inkbird-ble==0.5.6 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.3.5 +insteon-frontend-home-assistant==0.4.0 # homeassistant.components.intellifire intellifire4py==2.2.2 @@ -1302,7 +1302,7 @@ pyialarm==2.2.0 pyicloud==1.0.0 # homeassistant.components.insteon -pyinsteon==1.4.3 +pyinsteon==1.5.1 # homeassistant.components.ipma pyipma==3.0.6 From 458a3f0df27b110a275d017d96f0c5ec9378e68a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 21:12:01 +0200 Subject: [PATCH 431/640] Remove restore functionality in Speedtest.net (#96950) --- .../components/speedtestdotnet/sensor.py | 12 +------ tests/components/speedtestdotnet/conftest.py | 6 ++-- tests/components/speedtestdotnet/test_init.py | 25 +------------- .../components/speedtestdotnet/test_sensor.py | 34 ++----------------- 4 files changed, 6 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index 5bcf178f3963d1..ccd2008503c357 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import StateType from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -77,10 +76,7 @@ async def async_setup_entry( ) -# pylint: disable-next=hass-invalid-inheritance # needs fixing -class SpeedtestSensor( - CoordinatorEntity[SpeedTestDataCoordinator], RestoreEntity, SensorEntity -): +class SpeedtestSensor(CoordinatorEntity[SpeedTestDataCoordinator], SensorEntity): """Implementation of a speedtest.net sensor.""" entity_description: SpeedtestSensorEntityDescription @@ -134,9 +130,3 @@ def extra_state_attributes(self) -> dict[str, Any]: self._attrs[ATTR_BYTES_SENT] = self.coordinator.data[ATTR_BYTES_SENT] return self._attrs - - async def async_added_to_hass(self) -> None: - """Handle entity which will be added.""" - await super().async_added_to_hass() - if state := await self.async_get_last_state(): - self._state = state.state diff --git a/tests/components/speedtestdotnet/conftest.py b/tests/components/speedtestdotnet/conftest.py index 3324b92d8bdbce..0dab08eddef1e6 100644 --- a/tests/components/speedtestdotnet/conftest.py +++ b/tests/components/speedtestdotnet/conftest.py @@ -3,14 +3,12 @@ import pytest -from . import MOCK_RESULTS, MOCK_SERVERS +from . import MOCK_SERVERS -@pytest.fixture(autouse=True) +@pytest.fixture def mock_api(): """Mock entry setup.""" with patch("speedtest.Speedtest") as mock_api: mock_api.return_value.get_servers.return_value = MOCK_SERVERS - mock_api.return_value.get_best_server.return_value = MOCK_SERVERS[1][0] - mock_api.return_value.results.dict.return_value = MOCK_RESULTS yield mock_api diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index da19fd85dd35f4..c6804f48401b17 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -5,11 +5,7 @@ import speedtest -from homeassistant.components.speedtestdotnet.const import ( - CONF_SERVER_ID, - CONF_SERVER_NAME, - DOMAIN, -) +from homeassistant.components.speedtestdotnet.const import DOMAIN from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -18,25 +14,6 @@ from tests.common import MockConfigEntry, async_fire_time_changed -async def test_successful_config_entry(hass: HomeAssistant) -> None: - """Test that SpeedTestDotNet is configured successfully.""" - - entry = MockConfigEntry( - domain=DOMAIN, - data={}, - options={ - CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", - CONF_SERVER_ID: "1", - }, - ) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - - assert entry.state == ConfigEntryState.LOADED - assert hass.data[DOMAIN] - - async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: """Test SpeedTestDotNet failed due to an error.""" diff --git a/tests/components/speedtestdotnet/test_sensor.py b/tests/components/speedtestdotnet/test_sensor.py index 887f0ba0491225..d15e9fb92f4bfb 100644 --- a/tests/components/speedtestdotnet/test_sensor.py +++ b/tests/components/speedtestdotnet/test_sensor.py @@ -3,11 +3,11 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.speedtestdotnet import DOMAIN -from homeassistant.core import HomeAssistant, State +from homeassistant.core import HomeAssistant from . import MOCK_RESULTS, MOCK_SERVERS, MOCK_STATES -from tests.common import MockConfigEntry, mock_restore_cache +from tests.common import MockConfigEntry async def test_speedtestdotnet_sensors( @@ -36,33 +36,3 @@ async def test_speedtestdotnet_sensors( sensor = hass.states.get("sensor.speedtest_ping") assert sensor assert sensor.state == MOCK_STATES["ping"] - - -async def test_restore_last_state(hass: HomeAssistant, mock_api: MagicMock) -> None: - """Test restoring last state for sensors.""" - mock_restore_cache( - hass, - [ - State(f"sensor.speedtest_{sensor}", state) - for sensor, state in MOCK_STATES.items() - ], - ) - entry = MockConfigEntry(domain=DOMAIN) - entry.add_to_hass(hass) - - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert len(hass.states.async_entity_ids(SENSOR_DOMAIN)) == 3 - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] - - sensor = hass.states.get("sensor.speedtest_download") - assert sensor - assert sensor.state == MOCK_STATES["download"] - - sensor = hass.states.get("sensor.speedtest_ping") - assert sensor - assert sensor.state == MOCK_STATES["ping"] From eb0ab3de93bfb60dc8220ae136e7d9395ec1c458 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 21:28:29 +0200 Subject: [PATCH 432/640] User shorthand attr for mqtt alarm_control_panel (#100234) --- .../components/mqtt/alarm_control_panel.py | 34 ++++++------------- 1 file changed, 11 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a0939fdc615647..4639bd82eb3a03 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -161,8 +161,6 @@ def __init__( discovery_data: DiscoveryInfoType | None, ) -> None: """Init the MQTT Alarm Control Panel.""" - self._state: str | None = None - MqttEntity.__init__(self, hass, config, config_entry, discovery_data) @staticmethod @@ -183,6 +181,16 @@ def _setup_from_config(self, config: ConfigType) -> None: for feature in self._config[CONF_SUPPORTED_FEATURES]: self._attr_supported_features |= _SUPPORTED_FEATURES[feature] + 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) + ): + self._attr_code_format = alarm.CodeFormat.NUMBER + else: + self._attr_code_format = alarm.CodeFormat.TEXT + self._attr_code_arm_required = bool(self._config[CONF_CODE_ARM_REQUIRED]) + def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -205,7 +213,7 @@ def message_received(msg: ReceiveMessage) -> None: ): _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = str(payload) + self._attr_state = str(payload) get_mqtt_data(self.hass).state_write_requests.write_state_request(self) self._sub_state = subscription.async_prepare_subscribe_topics( @@ -225,26 +233,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def code_format(self) -> alarm.CodeFormat | None: - """Return one or more digits/characters.""" - code: str | None - if (code := self._config.get(CONF_CODE)) is None: - return None - if code == REMOTE_CODE or (isinstance(code, str) and re.search("^\\d+$", code)): - return alarm.CodeFormat.NUMBER - return alarm.CodeFormat.TEXT - - @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return bool(self._config[CONF_CODE_ARM_REQUIRED]) - async def async_alarm_disarm(self, code: str | None = None) -> None: """Send disarm command. From e3837cd1e00424c738597a74ff47be7acd20fb15 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Tue, 12 Sep 2023 22:21:13 +0200 Subject: [PATCH 433/640] Use shorthand attr for mqtt assumed_state (#100241) --- homeassistant/components/mqtt/cover.py | 6 +----- homeassistant/components/mqtt/fan.py | 6 +----- homeassistant/components/mqtt/humidifier.py | 6 +----- homeassistant/components/mqtt/lawn_mower.py | 16 ++++++---------- .../components/mqtt/light/schema_basic.py | 6 +----- .../components/mqtt/light/schema_json.py | 6 +----- .../components/mqtt/light/schema_template.py | 6 +----- homeassistant/components/mqtt/lock.py | 6 +----- homeassistant/components/mqtt/number.py | 13 ++++--------- homeassistant/components/mqtt/select.py | 15 ++++++--------- homeassistant/components/mqtt/siren.py | 6 +----- homeassistant/components/mqtt/switch.py | 6 +----- homeassistant/components/mqtt/text.py | 6 +----- 13 files changed, 26 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index ae22eb675ac215..3044e2d639648c 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -294,6 +294,7 @@ def _setup_from_config(self, config: ConfigType) -> None: ): # Force into optimistic mode. self._optimistic = True + self._attr_assumed_state = bool(self._optimistic) if ( config[CONF_TILT_STATE_OPTIMISTIC] @@ -488,11 +489,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) - @property def is_closed(self) -> bool | None: """Return true if the cover is closed or None if the status is unknown.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 58189c3cb3e8c3..5c7557c7598d81 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -295,6 +295,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_direction = ( optimistic or self._topic[CONF_DIRECTION_STATE_TOPIC] is None ) @@ -491,11 +492,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def is_on(self) -> bool | None: """Return true if device is on.""" diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index aebb05c19f7cd7..52d8db3fc9822c 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -260,6 +260,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_target_humidity = ( optimistic or self._topic[CONF_TARGET_HUMIDITY_STATE_TOPIC] is None ) @@ -465,11 +466,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the entity. diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 44db3581f8bc1a..fc3996ffbffb65 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -113,7 +113,6 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _command_topics: dict[str, str] _value_template: Callable[[ReceivePayloadType], ReceivePayloadType] - _optimistic: bool = False def __init__( self, @@ -134,7 +133,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._value_template = MqttValueTemplate( config.get(CONF_ACTIVITY_VALUE_TEMPLATE), entity=self @@ -198,7 +197,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -217,19 +216,16 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): with contextlib.suppress(ValueError): self._attr_activity = LawnMowerActivity(last_state.state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def _async_operate(self, option: str, activity: LawnMowerActivity) -> None: """Execute operation.""" payload = self._command_templates[option](option) - if self._optimistic: + if self._attr_assumed_state: self._attr_activity = activity self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 2a726075bb0da2..34b4a567ba53dd 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -330,6 +330,7 @@ def _setup_from_config(self, config: ConfigType) -> None: optimistic or topic[CONF_COLOR_MODE_STATE_TOPIC] is None ) self._optimistic = optimistic or topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._optimistic_rgb_color = optimistic or topic[CONF_RGB_STATE_TOPIC] is None self._optimistic_rgbw_color = optimistic or topic[CONF_RGBW_STATE_TOPIC] is None self._optimistic_rgbww_color = ( @@ -668,11 +669,6 @@ def restore_state( restore_state(ATTR_XY_COLOR) restore_state(ATTR_HS_COLOR, ATTR_XY_COLOR) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: # noqa: C901 """Turn the device on. diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index b778791216177b..11574b8879858b 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -215,6 +215,7 @@ def _setup_from_config(self, config: ConfigType) -> None: } optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or self._topic[CONF_STATE_TOPIC] is None + self._attr_assumed_state = bool(self._optimistic) self._flash_times = { key: config.get(key) @@ -462,11 +463,6 @@ async def _subscribe_topics(self) -> None: ) self._attr_xy_color = last_attributes.get(ATTR_XY_COLOR, self.xy_color) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def color_mode(self) -> ColorMode | str | None: """Return current color mode.""" diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 98ee7648eebd85..e811c45fc67181 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -179,6 +179,7 @@ def _setup_from_config(self, config: ConfigType) -> None: or self._topics[CONF_STATE_TOPIC] is None or CONF_STATE_TEMPLATE not in self._config ) + self._attr_assumed_state = bool(self._optimistic) color_modes = {ColorMode.ONOFF} if CONF_BRIGHTNESS_TEMPLATE in config: @@ -315,11 +316,6 @@ async def _subscribe_topics(self) -> None: if last_state.attributes.get(ATTR_EFFECT): self._attr_effect = last_state.attributes.get(ATTR_EFFECT) - @property - def assumed_state(self) -> bool: - """Return True if unable to access real state of the entity.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the entity on. diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index cb586c0630929f..d2e67ba40da3b2 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -159,6 +159,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._optimistic = ( config[CONF_OPTIMISTIC] or self._config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._compiled_pattern = config.get(CONF_CODE_FORMAT) self._attr_code_format = ( @@ -221,11 +222,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_lock(self, **kwargs: Any) -> None: """Lock the device. diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index 971b44b43bfccd..a88210a31980a2 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -161,7 +161,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" self._config = config - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._command_template = MqttCommandTemplate( config.get(CONF_COMMAND_TEMPLATE), entity=self @@ -218,7 +218,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -237,7 +237,7 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and ( + if self._attr_assumed_state and ( last_number_data := await self.async_get_last_number_data() ): self._attr_native_value = last_number_data.native_value @@ -250,7 +250,7 @@ async def async_set_native_value(self, value: float) -> None: current_number = int(value) payload = self._command_template(current_number) - if self._optimistic: + if self._attr_assumed_state: self._attr_native_value = current_number self.async_write_ha_state() @@ -261,8 +261,3 @@ async def async_set_native_value(self, value: float) -> None: self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index df8cf024bd26c3..1c4b33de0ee9d1 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -115,7 +115,7 @@ def config_schema() -> vol.Schema: def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" - self._optimistic = config[CONF_OPTIMISTIC] + self._attr_assumed_state = config[CONF_OPTIMISTIC] self._attr_options = config[CONF_OPTIONS] self._command_template = MqttCommandTemplate( @@ -152,7 +152,7 @@ def message_received(msg: ReceiveMessage) -> None: if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. - self._optimistic = True + self._attr_assumed_state = True else: self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, @@ -171,13 +171,15 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - if self._optimistic and (last_state := await self.async_get_last_state()): + if self._attr_assumed_state and ( + last_state := await self.async_get_last_state() + ): self._attr_current_option = last_state.state async def async_select_option(self, option: str) -> None: """Update the current value.""" payload = self._command_template(option) - if self._optimistic: + if self._attr_assumed_state: self._attr_current_option = option self.async_write_ha_state() @@ -188,8 +190,3 @@ async def async_select_option(self, option: str) -> None: self._config[CONF_RETAIN], self._config[CONF_ENCODING], ) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 328812a6e49ebe..aeabd0fe148007 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -194,6 +194,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._attr_supported_features = _supported_features self._optimistic = config[CONF_OPTIMISTIC] or CONF_STATE_TOPIC not in config + self._attr_assumed_state = bool(self._optimistic) self._attr_is_on = False if self._optimistic else None command_template: Template | None = config.get(CONF_COMMAND_TEMPLATE) @@ -301,11 +302,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes.""" diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 107b0b1cb10d99..e8872d3f0d1e96 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -125,6 +125,7 @@ def _setup_from_config(self, config: ConfigType) -> None: self._optimistic = ( config[CONF_OPTIMISTIC] or config.get(CONF_STATE_TOPIC) is None ) + self._attr_assumed_state = bool(self._optimistic) self._value_template = MqttValueTemplate( self._config.get(CONF_VALUE_TEMPLATE), entity=self @@ -171,11 +172,6 @@ async def _subscribe_topics(self) -> None: if self._optimistic and (last_state := await self.async_get_last_state()): self._attr_is_on = last_state.state == STATE_ON - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on. diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index 13677b7f35b223..6d1196cfd957f9 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -169,6 +169,7 @@ def _setup_from_config(self, config: ConfigType) -> None: ).async_render_with_possible_json_value optimistic: bool = config[CONF_OPTIMISTIC] self._optimistic = optimistic or config.get(CONF_STATE_TOPIC) is None + self._attr_assumed_state = bool(self._optimistic) def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -203,11 +204,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await subscription.async_subscribe_topics(self.hass, self._sub_state) - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return self._optimistic - async def async_set_value(self, value: str) -> None: """Change the text.""" payload = self._command_template(value) From f2fac40019bc8b3da599cd023f506e03b252d159 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Tue, 12 Sep 2023 22:21:58 +0200 Subject: [PATCH 434/640] Add strict typing to GPSD (#100030) Co-authored-by: epenet <6771947+epenet@users.noreply.github.com> --- .strict-typing | 1 + homeassistant/components/gpsd/sensor.py | 21 ++++++++++++++------- mypy.ini | 10 ++++++++++ 3 files changed, 25 insertions(+), 7 deletions(-) diff --git a/.strict-typing b/.strict-typing index 852ebbc0420534..c1138119f5f34d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -141,6 +141,7 @@ homeassistant.components.glances.* homeassistant.components.goalzero.* homeassistant.components.google.* homeassistant.components.google_sheets.* +homeassistant.components.gpsd.* homeassistant.components.greeneye_monitor.* homeassistant.components.group.* homeassistant.components.guardian.* diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 28ca9d3f075240..3e356f1509c8a4 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -3,6 +3,7 @@ import logging import socket +from typing import Any from gps3.agps3threaded import AGPS3mechanism import voluptuous as vol @@ -48,9 +49,9 @@ def setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the GPSD component.""" - name = config.get(CONF_NAME) - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) + name = config[CONF_NAME] + host = config[CONF_HOST] + port = config[CONF_PORT] # Will hopefully be possible with the next gps3 update # https://github.com/wadda/gps3/issues/11 @@ -77,7 +78,13 @@ def setup_platform( class GpsdSensor(SensorEntity): """Representation of a GPS receiver available via GPSD.""" - def __init__(self, hass, name, host, port): + def __init__( + self, + hass: HomeAssistant, + name: str, + host: str, + port: int, + ) -> None: """Initialize the GPSD sensor.""" self.hass = hass self._name = name @@ -89,12 +96,12 @@ def __init__(self, hass, name, host, port): self.agps_thread.run_thread() @property - def name(self): + def name(self) -> str: """Return the name.""" return self._name @property - def native_value(self): + def native_value(self) -> str | None: """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" @@ -103,7 +110,7 @@ def native_value(self): return None @property - def extra_state_attributes(self): + def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the GPS.""" return { ATTR_LATITUDE: self.agps_thread.data_stream.lat, diff --git a/mypy.ini b/mypy.ini index 6bade2728f41f5..3d6e4e1b2b6ca8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1172,6 +1172,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.gpsd.*] +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.greeneye_monitor.*] check_untyped_defs = true disallow_incomplete_defs = true From fa0b999d08def7725bed48c259234b100bc4e7ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Tue, 12 Sep 2023 23:22:10 +0300 Subject: [PATCH 435/640] Upgrade ruff to 0.0.289 (#100238) --- .pre-commit-config.yaml | 2 +- homeassistant/components/dynalite/dynalitebase.py | 2 +- homeassistant/components/ios/sensor.py | 2 +- homeassistant/components/websocket_api/sensor.py | 2 +- pyproject.toml | 1 + requirements_test_pre_commit.txt | 2 +- 6 files changed, 6 insertions(+), 5 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 1a38238e159e55..b5fafdd6dab43e 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.285 + rev: v0.0.289 hooks: - id: ruff args: diff --git a/homeassistant/components/dynalite/dynalitebase.py b/homeassistant/components/dynalite/dynalitebase.py index 43a4a5b106bf55..baf4c12a4c5690 100644 --- a/homeassistant/components/dynalite/dynalitebase.py +++ b/homeassistant/components/dynalite/dynalitebase.py @@ -70,7 +70,7 @@ def device_info(self) -> DeviceInfo: ) async def async_added_to_hass(self) -> None: - """Added to hass so need to restore state and register to dispatch.""" + """Handle addition to hass: restore state and register to dispatch.""" # register for device specific update await super().async_added_to_hass() diff --git a/homeassistant/components/ios/sensor.py b/homeassistant/components/ios/sensor.py index 45cd3586af2c92..610cea8c814a7a 100644 --- a/homeassistant/components/ios/sensor.py +++ b/homeassistant/components/ios/sensor.py @@ -137,7 +137,7 @@ def _update(self, device): self.async_write_ha_state() async def async_added_to_hass(self) -> None: - """Added to hass so need to register to dispatch.""" + """Handle addition to hass: register to dispatch.""" self._attr_native_value = self._device[ios.ATTR_BATTERY][ self.entity_description.key ] diff --git a/homeassistant/components/websocket_api/sensor.py b/homeassistant/components/websocket_api/sensor.py index 9377fcefd92e59..5857ead2c11283 100644 --- a/homeassistant/components/websocket_api/sensor.py +++ b/homeassistant/components/websocket_api/sensor.py @@ -34,7 +34,7 @@ def __init__(self) -> None: self.count = 0 async def async_added_to_hass(self) -> None: - """Added to hass.""" + """Handle addition to hass.""" self.async_on_remove( async_dispatcher_connect( self.hass, SIGNAL_WEBSOCKET_CONNECTED, self._update_count diff --git a/pyproject.toml b/pyproject.toml index 73f47998ea7943..7bab1c1b1225ba 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -289,6 +289,7 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 + "no-self-use", # PLR6301 # Handled by mypy # Ref: diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index 98c8f40b82b72c..dadc3e0cab270d 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.285 +ruff==0.0.289 yamllint==1.32.0 From 1b40a56e2b9ae204b400d100a03757a31cfbefae Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 15:24:38 -0500 Subject: [PATCH 436/640] Update ecobee zeroconf/homekit discovery (#100091) --- homeassistant/components/ecobee/manifest.json | 5 ++++- homeassistant/generated/zeroconf.py | 7 ++++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ecobee/manifest.json b/homeassistant/components/ecobee/manifest.json index 43f22e2b4d6172..71f5e04f75a7d4 100644 --- a/homeassistant/components/ecobee/manifest.json +++ b/homeassistant/components/ecobee/manifest.json @@ -5,12 +5,15 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/ecobee", "homekit": { - "models": ["EB-*", "ecobee*"] + "models": ["EB", "ecobee*"] }, "iot_class": "cloud_polling", "loggers": ["pyecobee"], "requirements": ["python-ecobee-api==0.2.14"], "zeroconf": [ + { + "type": "_ecobee._tcp.local." + }, { "type": "_sideplay._tcp.local.", "properties": { diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 3874a06ab4b457..36ddfd68479915 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -44,7 +44,7 @@ "always_discover": True, "domain": "roku", }, - "EB-*": { + "EB": { "always_discover": True, "domain": "ecobee", }, @@ -386,6 +386,11 @@ "name": "wac*", }, ], + "_ecobee._tcp.local.": [ + { + "domain": "ecobee", + }, + ], "_elg._tcp.local.": [ { "domain": "elgato", From 904913c1a6b0a89a979e97e041adeab8451d2a57 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 12 Sep 2023 22:37:51 +0200 Subject: [PATCH 437/640] Use shorthand attributes in VLC telnet (#99916) * Use shorthand attributes in VLC telnet * Apply suggestions from code review Co-authored-by: Martin Hjelmare * fix mypy * Attempt 3 --------- Co-authored-by: Martin Hjelmare --- .../components/vlc_telnet/media_player.py | 103 +++++------------- 1 file changed, 29 insertions(+), 74 deletions(-) diff --git a/homeassistant/components/vlc_telnet/media_player.py b/homeassistant/components/vlc_telnet/media_player.py index 87bc158331ea8e..ef1df676a2dde2 100644 --- a/homeassistant/components/vlc_telnet/media_player.py +++ b/homeassistant/components/vlc_telnet/media_player.py @@ -2,7 +2,6 @@ from __future__ import annotations from collections.abc import Awaitable, Callable, Coroutine -from datetime import datetime from functools import wraps from typing import Any, Concatenate, ParamSpec, TypeVar @@ -59,9 +58,9 @@ async def wrapper(self: _VlcDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> Non LOGGER.error("Command error: %s", err) except ConnectError as err: # pylint: disable=protected-access - if self._available: + if self._attr_available: LOGGER.error("Connection error: %s", err) - self._available = False + self._attr_available = False return wrapper @@ -86,22 +85,16 @@ class VlcDevice(MediaPlayerEntity): | MediaPlayerEntityFeature.VOLUME_SET | MediaPlayerEntityFeature.BROWSE_MEDIA ) + _volume_bkp = 0.0 + volume_level: int def __init__( self, config_entry: ConfigEntry, vlc: Client, name: str, available: bool ) -> None: """Initialize the vlc device.""" self._config_entry = config_entry - self._volume: float | None = None - self._muted: bool | None = None - self._media_position_updated_at: datetime | None = None - self._media_position: int | None = None - self._media_duration: int | None = None self._vlc = vlc - self._available = available - self._volume_bkp = 0.0 - self._media_artist: str | None = None - self._media_title: str | None = None + self._attr_available = available config_entry_id = config_entry.entry_id self._attr_unique_id = config_entry_id self._attr_device_info = DeviceInfo( @@ -115,7 +108,7 @@ def __init__( @catch_vlc_errors async def async_update(self) -> None: """Get the latest details from the device.""" - if not self._available: + if not self.available: try: await self._vlc.connect() except ConnectError as err: @@ -132,13 +125,13 @@ async def async_update(self) -> None: return self._attr_state = MediaPlayerState.IDLE - self._available = True + self._attr_available = True LOGGER.info("Connected to vlc host: %s", self._vlc.host) status = await self._vlc.status() LOGGER.debug("Status: %s", status) - self._volume = status.audio_volume / MAX_VOLUME + self._attr_volume_level = status.audio_volume / MAX_VOLUME state = status.state if state == "playing": self._attr_state = MediaPlayerState.PLAYING @@ -148,80 +141,42 @@ async def async_update(self) -> None: self._attr_state = MediaPlayerState.IDLE if self._attr_state != MediaPlayerState.IDLE: - self._media_duration = (await self._vlc.get_length()).length + self._attr_media_duration = (await self._vlc.get_length()).length time_output = await self._vlc.get_time() vlc_position = time_output.time # Check if current position is stale. - if vlc_position != self._media_position: - self._media_position_updated_at = dt_util.utcnow() - self._media_position = vlc_position + if vlc_position != self.media_position: + self._attr_media_position_updated_at = dt_util.utcnow() + self._attr_media_position = vlc_position info = await self._vlc.info() data = info.data LOGGER.debug("Info data: %s", data) self._attr_media_album_name = data.get("data", {}).get("album") - self._media_artist = data.get("data", {}).get("artist") - self._media_title = data.get("data", {}).get("title") + self._attr_media_artist = data.get("data", {}).get("artist") + self._attr_media_title = data.get("data", {}).get("title") now_playing = data.get("data", {}).get("now_playing") # Many radio streams put artist/title/album in now_playing and title is the station name. if now_playing: - if not self._media_artist: - self._media_artist = self._media_title - self._media_title = now_playing + if not self.media_artist: + self._attr_media_artist = self._attr_media_title + self._attr_media_title = now_playing - if self._media_title: + if self.media_title: return # Fall back to filename. if data_info := data.get("data"): - self._media_title = data_info["filename"] + self._attr_media_title = data_info["filename"] # Strip out auth signatures if streaming local media - if self._media_title and (pos := self._media_title.find("?authSig=")) != -1: - self._media_title = self._media_title[:pos] - - @property - def available(self) -> bool: - """Return True if entity is available.""" - return self._available - - @property - def volume_level(self) -> float | None: - """Volume level of the media player (0..1).""" - return self._volume - - @property - def is_volume_muted(self) -> bool | None: - """Boolean if volume is currently muted.""" - return self._muted - - @property - def media_duration(self) -> int | None: - """Duration of current playing media in seconds.""" - return self._media_duration - - @property - def media_position(self) -> int | None: - """Position of current playing media in seconds.""" - return self._media_position - - @property - def media_position_updated_at(self) -> datetime | None: - """When was the position of the current playing media valid.""" - return self._media_position_updated_at - - @property - def media_title(self) -> str | None: - """Title of current playing media.""" - return self._media_title - - @property - def media_artist(self) -> str | None: - """Artist of current playing media, music track only.""" - return self._media_artist + if (media_title := self.media_title) and ( + pos := media_title.find("?authSig=") + ) != -1: + self._attr_media_title = media_title[:pos] @catch_vlc_errors async def async_media_seek(self, position: float) -> None: @@ -231,24 +186,24 @@ async def async_media_seek(self, position: float) -> None: @catch_vlc_errors async def async_mute_volume(self, mute: bool) -> None: """Mute the volume.""" - assert self._volume is not None + assert self._attr_volume_level is not None if mute: - self._volume_bkp = self._volume + self._volume_bkp = self._attr_volume_level await self.async_set_volume_level(0) else: await self.async_set_volume_level(self._volume_bkp) - self._muted = mute + self._attr_is_volume_muted = mute @catch_vlc_errors async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" await self._vlc.set_volume(round(volume * MAX_VOLUME)) - self._volume = volume + self._attr_volume_level = volume - if self._muted and self._volume > 0: + if self.is_volume_muted and self.volume_level > 0: # This can happen if we were muted and then see a volume_up. - self._muted = False + self._attr_is_volume_muted = False @catch_vlc_errors async def async_media_play(self) -> None: From fc75172d79d3ad5d3cbcd2825ebbc1ea73937506 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 16:35:39 -0500 Subject: [PATCH 438/640] Bump async-upnp-client to 0.35.1 (#100248) --- 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 23c45b73ec5b00..53bda449465e62 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.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "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 2adb2e7634723f..d7a72a5341113f 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.35.0"], + "requirements": ["async-upnp-client==0.35.1"], "ssdp": [ { "deviceType": "urn:schemas-upnp-org:device:MediaServer:1", diff --git a/homeassistant/components/samsungtv/manifest.json b/homeassistant/components/samsungtv/manifest.json index 9461eb86af6b9c..be75e3f4465fa0 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.35.0" + "async-upnp-client==0.35.1" ], "ssdp": [ { diff --git a/homeassistant/components/ssdp/manifest.json b/homeassistant/components/ssdp/manifest.json index a6eb95933b4b64..c9cf452bac2b71 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.35.0"] + "requirements": ["async-upnp-client==0.35.1"] } diff --git a/homeassistant/components/upnp/manifest.json b/homeassistant/components/upnp/manifest.json index 95bb3e779669ce..e42235af7479dd 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.35.0", "getmac==0.8.2"], + "requirements": ["async-upnp-client==0.35.1", "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 993cc6ca4faf15..e510a58b3e7d4c 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.35.0"], + "requirements": ["yeelight==0.7.13", "async-upnp-client==0.35.1"], "zeroconf": [ { "type": "_miio._udp.local.", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index a5fb3856c05da7..bd6a130a2fc022 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -3,7 +3,7 @@ aiohttp-cors==0.7.0 aiohttp==3.8.5 astral==2.2 async-timeout==4.0.3 -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 awesomeversion==22.9.0 diff --git a/requirements_all.txt b/requirements_all.txt index a1a3a598568b7e..72cc1d9479e2a0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -456,7 +456,7 @@ asterisk-mbox==0.5.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 # homeassistant.components.esphome async_interrupt==1.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4d366125d9f014..0e026726b6ae99 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -410,7 +410,7 @@ arcam-fmj==1.4.0 # homeassistant.components.ssdp # homeassistant.components.upnp # homeassistant.components.yeelight -async-upnp-client==0.35.0 +async-upnp-client==0.35.1 # homeassistant.components.esphome async_interrupt==1.1.1 From 8aa689ebae92a0a5d66a3a0f84f03881d8dda62a Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Tue, 12 Sep 2023 23:36:44 +0200 Subject: [PATCH 439/640] Bump pynetgear to 0.10.10 (#100242) --- homeassistant/components/netgear/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/netgear/manifest.json b/homeassistant/components/netgear/manifest.json index be4dd0f2d9db5e..59a41542d7cf8e 100644 --- a/homeassistant/components/netgear/manifest.json +++ b/homeassistant/components/netgear/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/netgear", "iot_class": "local_polling", "loggers": ["pynetgear"], - "requirements": ["pynetgear==0.10.9"], + "requirements": ["pynetgear==0.10.10"], "ssdp": [ { "manufacturer": "NETGEAR, Inc.", diff --git a/requirements_all.txt b/requirements_all.txt index 72cc1d9479e2a0..23e58f00b19328 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1870,7 +1870,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.netio pynetio==0.1.9.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0e026726b6ae99..8a8655d2d66b4c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1392,7 +1392,7 @@ pymyq==3.1.4 pymysensors==0.24.0 # homeassistant.components.netgear -pynetgear==0.10.9 +pynetgear==0.10.10 # homeassistant.components.nobo_hub pynobo==1.6.0 From bbcae19d0e99102b7f5b582c96c966ed71994e65 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 12 Sep 2023 17:15:13 -0500 Subject: [PATCH 440/640] Disable always responding to all SSDP M-SEARCH requests with the root device (#100224) --- homeassistant/components/ssdp/__init__.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/homeassistant/components/ssdp/__init__.py b/homeassistant/components/ssdp/__init__.py index aaffc5a157afa3..ded663af897687 100644 --- a/homeassistant/components/ssdp/__init__.py +++ b/homeassistant/components/ssdp/__init__.py @@ -23,13 +23,7 @@ SsdpSource, ) from async_upnp_client.description_cache import DescriptionCache -from async_upnp_client.server import ( - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE, - SSDP_SEARCH_RESPONDER_OPTIONS, - UpnpServer, - UpnpServerDevice, - UpnpServerService, -) +from async_upnp_client.server import UpnpServer, UpnpServerDevice, UpnpServerService from async_upnp_client.ssdp import ( SSDP_PORT, determine_source_target, @@ -796,11 +790,6 @@ async def _async_start_upnp_servers(self, event: Event) -> None: http_port=http_port, server_device=HassUpnpServiceDevice, boot_id=boot_id, - options={ - SSDP_SEARCH_RESPONDER_OPTIONS: { - SSDP_SEARCH_RESPONDER_OPTION_ALWAYS_REPLY_WITH_ROOT_DEVICE: True - } - }, ) ) results = await asyncio.gather( From f344000ef9b60825bf33371da2115e8d65ecc0c7 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Tue, 12 Sep 2023 18:17:29 -0400 Subject: [PATCH 441/640] Bump pyenphase to 1.11.2 (#100249) * Bump pyenphase to 1.11.1 * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- 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 c6d127a3f6eea8..9fc6b63edfcfe1 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.0"], + "requirements": ["pyenphase==1.11.2"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index 23e58f00b19328..573bdc2e5f1f5a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.0 +pyenphase==1.11.2 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a8655d2d66b4c..b451f480e2a8b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.0 +pyenphase==1.11.2 # homeassistant.components.everlights pyeverlights==0.1.0 From 5272387bd311d7a6b9895ebf2664d6776fadf495 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 12 Sep 2023 19:16:31 -0400 Subject: [PATCH 442/640] Fix incorrect off peak translation key for Roborock (#100246) fix incorrect translation key --- homeassistant/components/roborock/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 0170c8ac706186..92d53c2e6bd1fd 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -192,10 +192,10 @@ "dnd_end_time": { "name": "Do not disturb end" }, - "off_peak_start_time": { + "off_peak_start": { "name": "Off-peak start" }, - "off_peak_end_time": { + "off_peak_end": { "name": "Off-peak end" } }, From fe85b20502dff82ead74dcf1d93390b8927b5cfb Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 13 Sep 2023 01:24:49 +0200 Subject: [PATCH 443/640] SamsungTV: Add unique_id for when missing (legacy models) (#96829) * Add unique_id for when missing (legacy models) * add comment * update tests, thx @epenet --- homeassistant/components/samsungtv/entity.py | 3 +- .../samsungtv/snapshots/test_init.ambr | 55 +++++++++++++++++++ tests/components/samsungtv/test_init.py | 13 ++++- 3 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/entity.py b/homeassistant/components/samsungtv/entity.py index e0ecbaac024b65..2b6373efc24003 100644 --- a/homeassistant/components/samsungtv/entity.py +++ b/homeassistant/components/samsungtv/entity.py @@ -21,7 +21,8 @@ def __init__(self, *, bridge: SamsungTVBridge, config_entry: ConfigEntry) -> Non self._bridge = bridge self._mac = config_entry.data.get(CONF_MAC) self._attr_name = config_entry.data.get(CONF_NAME) - self._attr_unique_id = config_entry.unique_id + # 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 diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr new file mode 100644 index 00000000000000..f8b11bd864a102 --- /dev/null +++ b/tests/components/samsungtv/snapshots/test_init.ambr @@ -0,0 +1,55 @@ +# serializer version: 1 +# name: test_setup_updates_from_ssdp + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'tv', + 'friendly_name': 'any', + 'is_volume_muted': False, + 'source_list': list([ + 'TV', + 'HDMI', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.any', + 'last_changed': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_setup_updates_from_ssdp.1 + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'TV', + 'HDMI', + ]), + }), + 'config_entry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.any', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'any', + 'platform': 'samsungtv', + 'supported_features': , + 'translation_key': None, + 'unique_id': 'sample-entry-id', + 'unit_of_measurement': None, + }) +# --- diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 7491f3b76b79ce..526f7a12fedcb0 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,6 +2,7 @@ from unittest.mock import Mock, patch import pytest +from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( @@ -30,6 +31,7 @@ SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from . import setup_samsungtv_entry from .const import ( @@ -115,9 +117,13 @@ async def test_setup_h_j_model( @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_updates_from_ssdp(hass: HomeAssistant) -> None: +async def test_setup_updates_from_ssdp( + hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion +) -> None: """Test setting up the entry fetches data from ssdp cache.""" - entry = MockConfigEntry(domain="samsungtv", data=MOCK_ENTRYDATA_WS) + entry = MockConfigEntry( + domain="samsungtv", data=MOCK_ENTRYDATA_WS, entry_id="sample-entry-id" + ) entry.add_to_hass(hass) async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str): @@ -135,7 +141,8 @@ async def _mock_async_get_discovery_info_by_st(hass: HomeAssistant, mock_st: str await hass.async_block_till_done() await hass.async_block_till_done() - assert hass.states.get("media_player.any") + assert hass.states.get("media_player.any") == snapshot + assert entity_registry.async_get("media_player.any") == snapshot assert ( entry.data[CONF_SSDP_MAIN_TV_AGENT_LOCATION] == "https://fake_host:12345/tv_agent" From 2518fbc9734aaf91ae5c40b86158e6b40ff38952 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Sep 2023 01:41:50 +0200 Subject: [PATCH 444/640] Update jsonpath to 0.82.2 (#100252) --- homeassistant/components/rest/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/rest/manifest.json b/homeassistant/components/rest/manifest.json index c8796c7161cd79..d638c20d2a4e59 100644 --- a/homeassistant/components/rest/manifest.json +++ b/homeassistant/components/rest/manifest.json @@ -4,5 +4,5 @@ "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/rest", "iot_class": "local_polling", - "requirements": ["jsonpath==0.82", "xmltodict==0.13.0"] + "requirements": ["jsonpath==0.82.2", "xmltodict==0.13.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 573bdc2e5f1f5a..d63abfddcb7a0e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1085,7 +1085,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b451f480e2a8b6..80877b96faa45f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -850,7 +850,7 @@ jaraco.abode==3.3.0 jellyfin-apiclient-python==1.9.2 # homeassistant.components.rest -jsonpath==0.82 +jsonpath==0.82.2 # homeassistant.components.justnimbus justnimbus==0.6.0 From f5aa2559d7dc3b7cf2e0f61f470ad9ee33a461da Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 13 Sep 2023 08:14:01 +0200 Subject: [PATCH 445/640] Fix pylint config warning (#100251) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7bab1c1b1225ba..7bc3edc9bf0eb7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -289,7 +289,7 @@ disable = [ "use-list-literal", # C405 "useless-object-inheritance", # UP004 "useless-return", # PLR1711 - "no-self-use", # PLR6301 + # "no-self-use", # PLR6301 # Optional plugin, not enabled # Handled by mypy # Ref: From 270df003fe4ed2a013150bc6a41076a2d2eb0797 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Sep 2023 02:15:33 -0400 Subject: [PATCH 446/640] Bump pyenphase to 1.11.3 (#100255) --- 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 9fc6b63edfcfe1..aa801fea14ed72 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.2"], + "requirements": ["pyenphase==1.11.3"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index d63abfddcb7a0e..379cb8e56797d4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.2 +pyenphase==1.11.3 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 80877b96faa45f..6b4f12206f6217 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.2 +pyenphase==1.11.3 # homeassistant.components.everlights pyeverlights==0.1.0 From 756f542ac62cf6621eba787e08059cb6275e8e38 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Tue, 12 Sep 2023 23:18:07 -0700 Subject: [PATCH 447/640] Update apple_weatherkit to 1.0.2 (#100254) --- 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 984e36483c7ff8..1e8bb8ba5c5bc1 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.1"] + "requirements": ["apple_weatherkit==1.0.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 379cb8e56797d4..f591dfb559a7fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,7 +424,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.1 +apple_weatherkit==1.0.2 # homeassistant.components.apprise apprise==1.4.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6b4f12206f6217..34d559520ce586 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -390,7 +390,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.1 +apple_weatherkit==1.0.2 # homeassistant.components.apprise apprise==1.4.5 From 09f58ec396672fddd37deea11647bd283b3bc66f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 13 Sep 2023 02:33:48 -0400 Subject: [PATCH 448/640] Bump python-roborock to 0.34.0 (#100236) --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/roborock/snapshots/test_diagnostics.ambr | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index dfcac67d2b0589..81bbd07d904c88 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.33.2"] + "requirements": ["python-roborock==0.34.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index f591dfb559a7fe..42eaf990045278 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2163,7 +2163,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.33.2 +python-roborock==0.34.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 34d559520ce586..17a9d08d37cc47 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1595,7 +1595,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.33.2 +python-roborock==0.34.0 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index eb70e04110f29a..a766a6c27032b3 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -225,11 +225,13 @@ 'area': 20965000, 'avoidCount': 19, 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', 'cleanType': 3, 'complete': 1, 'duration': 1176, 'dustCollectionStatus': 1, 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', 'error': 0, 'finishReason': 56, 'mapFlag': 0, From e87603aa5942597f5199eb891a1443f9da23e8f1 Mon Sep 17 00:00:00 2001 From: John Hollowell Date: Wed, 13 Sep 2023 02:35:59 -0400 Subject: [PATCH 449/640] Correct Venstar firmware version to use device's FW version instead of API version (#98493) --- CODEOWNERS | 4 ++-- homeassistant/components/venstar/__init__.py | 2 +- homeassistant/components/venstar/manifest.json | 2 +- tests/components/venstar/__init__.py | 3 ++- 4 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 9771a9e25e5393..bba1c2debbffa7 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1361,8 +1361,8 @@ build.json @home-assistant/supervisor /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra /homeassistant/components/velux/ @Julius2342 -/homeassistant/components/venstar/ @garbled1 -/tests/components/venstar/ @garbled1 +/homeassistant/components/venstar/ @garbled1 @jhollowe +/tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/verisure/ @frenck /tests/components/verisure/ @frenck /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/venstar/__init__.py b/homeassistant/components/venstar/__init__.py index a92d495f6af0ce..1416bcf376ad15 100644 --- a/homeassistant/components/venstar/__init__.py +++ b/homeassistant/components/venstar/__init__.py @@ -153,5 +153,5 @@ def device_info(self) -> DeviceInfo: name=self._client.name, manufacturer="Venstar", model=f"{self._client.model}-{self._client.get_type()}", - sw_version=self._client.get_api_ver(), + sw_version="{}.{}".format(*(self._client.get_firmware_ver())), ) diff --git a/homeassistant/components/venstar/manifest.json b/homeassistant/components/venstar/manifest.json index 39cbe0d35290dc..f3045fe49e8a70 100644 --- a/homeassistant/components/venstar/manifest.json +++ b/homeassistant/components/venstar/manifest.json @@ -1,7 +1,7 @@ { "domain": "venstar", "name": "Venstar", - "codeowners": ["@garbled1"], + "codeowners": ["@garbled1", "@jhollowe"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/venstar", "iot_class": "local_polling", diff --git a/tests/components/venstar/__init__.py b/tests/components/venstar/__init__.py index fa35dd88379907..f91f8f28bdfb8b 100644 --- a/tests/components/venstar/__init__.py +++ b/tests/components/venstar/__init__.py @@ -18,7 +18,8 @@ def __init__( """Initialize the Venstar library.""" self.status = {} self.model = "COLORTOUCH" - self._api_ver = 5 + self._api_ver = 7 + self._firmware_ver = tuple(5, 28) self.name = "TestVenstar" self._info = {} self._sensors = {} From dd95b51d108211b60524c6d245030099c01a8b58 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Wed, 13 Sep 2023 00:22:58 -0700 Subject: [PATCH 450/640] Address weatherkit late review comments (#100265) * Address review comments from original weatherkit PR * Use .get() for optional fields --- .../components/weatherkit/config_flow.py | 2 +- homeassistant/components/weatherkit/const.py | 5 +- .../components/weatherkit/weather.py | 123 ++++++++++-------- .../components/weatherkit/test_config_flow.py | 40 +++--- tests/components/weatherkit/test_setup.py | 10 +- 5 files changed, 94 insertions(+), 86 deletions(-) diff --git a/homeassistant/components/weatherkit/config_flow.py b/homeassistant/components/weatherkit/config_flow.py index d9db70dde114a5..5762c4ae9b2dce 100644 --- a/homeassistant/components/weatherkit/config_flow.py +++ b/homeassistant/components/weatherkit/config_flow.py @@ -120,7 +120,7 @@ async def _test_config(self, user_input: dict[str, Any]) -> None: location[CONF_LONGITUDE], ) - if len(availability) == 0: + if not availability: raise WeatherKitUnsupportedLocationError( "API does not support this location" ) diff --git a/homeassistant/components/weatherkit/const.py b/homeassistant/components/weatherkit/const.py index f2ef7e4c7202db..590ca65c9a9e2c 100644 --- a/homeassistant/components/weatherkit/const.py +++ b/homeassistant/components/weatherkit/const.py @@ -5,7 +5,10 @@ NAME = "Apple WeatherKit" DOMAIN = "weatherkit" -ATTRIBUTION = "Data provided by Apple Weather. https://developer.apple.com/weatherkit/data-source-attribution/" +ATTRIBUTION = ( + "Data provided by Apple Weather. " + "https://developer.apple.com/weatherkit/data-source-attribution/" +) CONF_KEY_ID = "key_id" CONF_SERVICE_ID = "service_id" diff --git a/homeassistant/components/weatherkit/weather.py b/homeassistant/components/weatherkit/weather.py index fc6b0dac1cba2d..07745680b010a5 100644 --- a/homeassistant/components/weatherkit/weather.py +++ b/homeassistant/components/weatherkit/weather.py @@ -5,6 +5,18 @@ from apple_weatherkit import DataSetType from homeassistant.components.weather import ( + ATTR_CONDITION_CLOUDY, + ATTR_CONDITION_EXCEPTIONAL, + ATTR_CONDITION_FOG, + ATTR_CONDITION_HAIL, + ATTR_CONDITION_LIGHTNING, + ATTR_CONDITION_PARTLYCLOUDY, + ATTR_CONDITION_POURING, + ATTR_CONDITION_RAINY, + ATTR_CONDITION_SNOWY, + ATTR_CONDITION_SNOWY_RAINY, + ATTR_CONDITION_SUNNY, + ATTR_CONDITION_WINDY, Forecast, SingleCoordinatorWeatherEntity, WeatherEntityFeature, @@ -40,71 +52,71 @@ async def async_setup_entry( condition_code_to_hass = { - "BlowingDust": "windy", - "Clear": "sunny", - "Cloudy": "cloudy", - "Foggy": "fog", - "Haze": "fog", - "MostlyClear": "sunny", - "MostlyCloudy": "cloudy", - "PartlyCloudy": "partlycloudy", - "Smoky": "fog", - "Breezy": "windy", - "Windy": "windy", - "Drizzle": "rainy", - "HeavyRain": "pouring", - "IsolatedThunderstorms": "lightning", - "Rain": "rainy", - "SunShowers": "rainy", - "ScatteredThunderstorms": "lightning", - "StrongStorms": "lightning", - "Thunderstorms": "lightning", - "Frigid": "snowy", - "Hail": "hail", - "Hot": "sunny", - "Flurries": "snowy", - "Sleet": "snowy", - "Snow": "snowy", - "SunFlurries": "snowy", - "WintryMix": "snowy", - "Blizzard": "snowy", - "BlowingSnow": "snowy", - "FreezingDrizzle": "snowy-rainy", - "FreezingRain": "snowy-rainy", - "HeavySnow": "snowy", - "Hurricane": "exceptional", - "TropicalStorm": "exceptional", + "BlowingDust": ATTR_CONDITION_WINDY, + "Clear": ATTR_CONDITION_SUNNY, + "Cloudy": ATTR_CONDITION_CLOUDY, + "Foggy": ATTR_CONDITION_FOG, + "Haze": ATTR_CONDITION_FOG, + "MostlyClear": ATTR_CONDITION_SUNNY, + "MostlyCloudy": ATTR_CONDITION_CLOUDY, + "PartlyCloudy": ATTR_CONDITION_PARTLYCLOUDY, + "Smoky": ATTR_CONDITION_FOG, + "Breezy": ATTR_CONDITION_WINDY, + "Windy": ATTR_CONDITION_WINDY, + "Drizzle": ATTR_CONDITION_RAINY, + "HeavyRain": ATTR_CONDITION_POURING, + "IsolatedThunderstorms": ATTR_CONDITION_LIGHTNING, + "Rain": ATTR_CONDITION_RAINY, + "SunShowers": ATTR_CONDITION_RAINY, + "ScatteredThunderstorms": ATTR_CONDITION_LIGHTNING, + "StrongStorms": ATTR_CONDITION_LIGHTNING, + "Thunderstorms": ATTR_CONDITION_LIGHTNING, + "Frigid": ATTR_CONDITION_SNOWY, + "Hail": ATTR_CONDITION_HAIL, + "Hot": ATTR_CONDITION_SUNNY, + "Flurries": ATTR_CONDITION_SNOWY, + "Sleet": ATTR_CONDITION_SNOWY, + "Snow": ATTR_CONDITION_SNOWY, + "SunFlurries": ATTR_CONDITION_SNOWY, + "WintryMix": ATTR_CONDITION_SNOWY, + "Blizzard": ATTR_CONDITION_SNOWY, + "BlowingSnow": ATTR_CONDITION_SNOWY, + "FreezingDrizzle": ATTR_CONDITION_SNOWY_RAINY, + "FreezingRain": ATTR_CONDITION_SNOWY_RAINY, + "HeavySnow": ATTR_CONDITION_SNOWY, + "Hurricane": ATTR_CONDITION_EXCEPTIONAL, + "TropicalStorm": ATTR_CONDITION_EXCEPTIONAL, } -def _map_daily_forecast(forecast) -> Forecast: +def _map_daily_forecast(forecast: dict[str, Any]) -> Forecast: return { - "datetime": forecast.get("forecastStart"), - "condition": condition_code_to_hass[forecast.get("conditionCode")], - "native_temperature": forecast.get("temperatureMax"), - "native_templow": forecast.get("temperatureMin"), - "native_precipitation": forecast.get("precipitationAmount"), - "precipitation_probability": forecast.get("precipitationChance") * 100, - "uv_index": forecast.get("maxUvIndex"), + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperatureMax"], + "native_templow": forecast["temperatureMin"], + "native_precipitation": forecast["precipitationAmount"], + "precipitation_probability": forecast["precipitationChance"] * 100, + "uv_index": forecast["maxUvIndex"], } -def _map_hourly_forecast(forecast) -> Forecast: +def _map_hourly_forecast(forecast: dict[str, Any]) -> Forecast: return { - "datetime": forecast.get("forecastStart"), - "condition": condition_code_to_hass[forecast.get("conditionCode")], - "native_temperature": forecast.get("temperature"), - "native_apparent_temperature": forecast.get("temperatureApparent"), + "datetime": forecast["forecastStart"], + "condition": condition_code_to_hass[forecast["conditionCode"]], + "native_temperature": forecast["temperature"], + "native_apparent_temperature": forecast["temperatureApparent"], "native_dew_point": forecast.get("temperatureDewPoint"), - "native_pressure": forecast.get("pressure"), + "native_pressure": forecast["pressure"], "native_wind_gust_speed": forecast.get("windGust"), - "native_wind_speed": forecast.get("windSpeed"), + "native_wind_speed": forecast["windSpeed"], "wind_bearing": forecast.get("windDirection"), - "humidity": forecast.get("humidity") * 100, + "humidity": forecast["humidity"] * 100, "native_precipitation": forecast.get("precipitationAmount"), - "precipitation_probability": forecast.get("precipitationChance") * 100, - "cloud_coverage": forecast.get("cloudCover") * 100, - "uv_index": forecast.get("uvIndex"), + "precipitation_probability": forecast["precipitationChance"] * 100, + "cloud_coverage": forecast["cloudCover"] * 100, + "uv_index": forecast["uvIndex"], } @@ -142,10 +154,11 @@ def __init__( @property def supported_features(self) -> WeatherEntityFeature: """Determine supported features based on available data sets reported by WeatherKit.""" + features = WeatherEntityFeature(0) + if not self.coordinator.supported_data_sets: - return WeatherEntityFeature(0) + return features - features = WeatherEntityFeature(0) if DataSetType.DAILY_FORECAST in self.coordinator.supported_data_sets: features |= WeatherEntityFeature.FORECAST_DAILY if DataSetType.HOURLY_FORECAST in self.coordinator.supported_data_sets: diff --git a/tests/components/weatherkit/test_config_flow.py b/tests/components/weatherkit/test_config_flow.py index 4faaac15db6145..3b6cf76a3d53db 100644 --- a/tests/components/weatherkit/test_config_flow.py +++ b/tests/components/weatherkit/test_config_flow.py @@ -40,26 +40,6 @@ } -async def _test_exception_generates_error( - hass: HomeAssistant, exception: Exception, error: str -) -> None: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - with patch( - "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", - side_effect=exception, - ): - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - EXAMPLE_USER_INPUT, - ) - - assert result["type"] == FlowResultType.FORM - assert result["errors"] == {"base": error} - - async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: """Test we get the form and create an entry.""" result = await hass.config_entries.flow.async_init( @@ -69,8 +49,8 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.weatherkit.config_flow.WeatherKitFlowHandler._test_config", - return_value=None, + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + return_value=[DataSetType.CURRENT_WEATHER], ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -100,7 +80,21 @@ async def test_error_handling( hass: HomeAssistant, exception: Exception, expected_error: str ) -> None: """Test that we handle various exceptions and generate appropriate errors.""" - await _test_exception_generates_error(hass, exception, expected_error) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.weatherkit.WeatherKitApiClient.get_availability", + side_effect=exception, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + EXAMPLE_USER_INPUT, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": expected_error} async def test_form_unsupported_location(hass: HomeAssistant) -> None: diff --git a/tests/components/weatherkit/test_setup.py b/tests/components/weatherkit/test_setup.py index 5f94d4100d543a..d71ecbda1b0218 100644 --- a/tests/components/weatherkit/test_setup.py +++ b/tests/components/weatherkit/test_setup.py @@ -5,13 +5,10 @@ WeatherKitApiClientAuthenticationError, WeatherKitApiClientError, ) -import pytest from homeassistant import config_entries -from homeassistant.components.weatherkit import async_setup_entry from homeassistant.components.weatherkit.const import DOMAIN from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryNotReady from . import EXAMPLE_CONFIG_DATA @@ -50,7 +47,7 @@ async def test_client_error_handling(hass: HomeAssistant) -> None: data=EXAMPLE_CONFIG_DATA, ) - with pytest.raises(ConfigEntryNotReady), patch( + with patch( "homeassistant.components.weatherkit.WeatherKitApiClient.get_weather_data", side_effect=WeatherKitApiClientError, ), patch( @@ -58,6 +55,7 @@ async def test_client_error_handling(hass: HomeAssistant) -> None: side_effect=WeatherKitApiClientError, ): entry.add_to_hass(hass) - config_entries.current_entry.set(entry) - await async_setup_entry(hass, entry) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + + assert entry.state == config_entries.ConfigEntryState.SETUP_RETRY From 1c10091d620218044e858f083c4400645d2ad74b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 13 Sep 2023 09:34:45 +0200 Subject: [PATCH 451/640] Bump docker/login-action from 2.2.0 to 3.0.0 (#100264) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/builder.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 0694b1b75e0bbd..0b0983a001f2af 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -190,7 +190,7 @@ jobs: echo "${{ github.sha }};${{ github.ref }};${{ github.event_name }};${{ github.actor }}" > rootfs/OFFICIAL_IMAGE - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -268,7 +268,7 @@ jobs: fi - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} @@ -339,13 +339,13 @@ jobs: cosign-release: "v2.0.2" - name: Login to DockerHub - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Login to GitHub Container Registry - uses: docker/login-action@v2.2.0 + uses: docker/login-action@v3.0.0 with: registry: ghcr.io username: ${{ github.repository_owner }} From e5de7eacadeaee2b4ad631aaa9fbbc4305ea5035 Mon Sep 17 00:00:00 2001 From: Aarni Koskela Date: Wed, 13 Sep 2023 11:21:55 +0300 Subject: [PATCH 452/640] Bump sensirion-ble to 0.1.1 (#100271) Bump to sensirion-ble==0.1.1 Fixes akx/sensirion-ble#6 Refs https://github.com/home-assistant/core/issues/93678#issuecomment-1694522112 --- homeassistant/components/sensirion_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/sensirion_ble/manifest.json b/homeassistant/components/sensirion_ble/manifest.json index 38f66a88e8e06d..01ccc873f56f34 100644 --- a/homeassistant/components/sensirion_ble/manifest.json +++ b/homeassistant/components/sensirion_ble/manifest.json @@ -16,5 +16,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/sensirion_ble", "iot_class": "local_push", - "requirements": ["sensirion-ble==0.1.0"] + "requirements": ["sensirion-ble==0.1.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 42eaf990045278..9f957e886d7bba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2379,7 +2379,7 @@ sense-energy==0.12.1 sense_energy==0.12.1 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 17a9d08d37cc47..eaeaa7496a834e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1745,7 +1745,7 @@ sense-energy==0.12.1 sense_energy==0.12.1 # homeassistant.components.sensirion_ble -sensirion-ble==0.1.0 +sensirion-ble==0.1.1 # homeassistant.components.sensorpro sensorpro-ble==0.5.3 From aedd06b9a9257addd6108f98a80598c75f703673 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 11:14:01 +0200 Subject: [PATCH 453/640] Tweak entity/source WS command handler (#100272) --- homeassistant/components/websocket_api/commands.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index e140fef861e05c..bd7d3b530cdf3e 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -599,10 +599,10 @@ def _serialize_entity_sources( entity_infos: dict[str, entity.EntityInfo] ) -> dict[str, Any]: """Prepare a websocket response from a dict of entity sources.""" - result = {} - for entity_id, entity_info in entity_infos.items(): - result[entity_id] = {"domain": entity_info["domain"]} - return result + return { + entity_id: {"domain": entity_info["domain"]} + for entity_id, entity_info in entity_infos.items() + } @callback From 29d8be510e38a1b8e87e2e41dde155dd39f12569 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 12:40:26 +0200 Subject: [PATCH 454/640] Test speedtest.net config entry lifecycle (#100280) --- tests/components/speedtestdotnet/test_init.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/tests/components/speedtestdotnet/test_init.py b/tests/components/speedtestdotnet/test_init.py index c6804f48401b17..5083f56a8e2ab0 100644 --- a/tests/components/speedtestdotnet/test_init.py +++ b/tests/components/speedtestdotnet/test_init.py @@ -5,7 +5,11 @@ import speedtest -from homeassistant.components.speedtestdotnet.const import DOMAIN +from homeassistant.components.speedtestdotnet.const import ( + CONF_SERVER_ID, + CONF_SERVER_NAME, + DOMAIN, +) from homeassistant.config_entries import ConfigEntryState from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import HomeAssistant @@ -27,16 +31,24 @@ async def test_setup_failed(hass: HomeAssistant, mock_api: MagicMock) -> None: assert entry.state is ConfigEntryState.SETUP_RETRY -async def test_unload_entry(hass: HomeAssistant) -> None: - """Test removing SpeedTestDotNet.""" +async def test_entry_lifecycle(hass: HomeAssistant, mock_api: MagicMock) -> None: + """Test the SpeedTestDotNet entry lifecycle.""" entry = MockConfigEntry( domain=DOMAIN, + data={}, + options={ + CONF_SERVER_NAME: "Country1 - Sponsor1 - Server1", + CONF_SERVER_ID: "1", + }, ) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + assert hass.data[DOMAIN] + assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() From 4f8e28a78150a616080b739107129bab95859b2c Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 12:41:28 +0200 Subject: [PATCH 455/640] Future proof assist_pipeline.Pipeline (#100277) --- .../components/assist_pipeline/pipeline.py | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/assist_pipeline/pipeline.py b/homeassistant/components/assist_pipeline/pipeline.py index 520daa9f5c21a5..f4d060ed7b82d5 100644 --- a/homeassistant/components/assist_pipeline/pipeline.py +++ b/homeassistant/components/assist_pipeline/pipeline.py @@ -298,6 +298,26 @@ class Pipeline: id: str = field(default_factory=ulid_util.ulid) + @classmethod + def from_json(cls, data: dict[str, Any]) -> Pipeline: + """Create an instance from a JSON serialization. + + This function was added in HA Core 2023.10, previous versions will raise + if there are unexpected items in the serialized data. + """ + return cls( + conversation_engine=data["conversation_engine"], + conversation_language=data["conversation_language"], + id=data["id"], + language=data["language"], + name=data["name"], + stt_engine=data["stt_engine"], + stt_language=data["stt_language"], + tts_engine=data["tts_engine"], + tts_language=data["tts_language"], + tts_voice=data["tts_voice"], + ) + def to_json(self) -> dict[str, Any]: """Return a JSON serializable representation for storage.""" return { @@ -1205,7 +1225,7 @@ def _create_item(self, item_id: str, data: dict) -> Pipeline: def _deserialize_item(self, data: dict) -> Pipeline: """Create an item from its serialized representation.""" - return Pipeline(**data) + return Pipeline.from_json(data) def _serialize_item(self, item_id: str, item: Pipeline) -> dict: """Return the serialized representation of an item for storing.""" From 684b2d45370813834c5dff613740d3a0a124f3ce Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 12:42:06 +0200 Subject: [PATCH 456/640] Improve type hint in entity_registry (#100278) --- homeassistant/helpers/entity_registry.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index ff2ca255279e84..939c8986e71811 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -430,7 +430,7 @@ async def _async_migrate_func( return data -class EntityRegistryItems(UserDict[str, "RegistryEntry"]): +class EntityRegistryItems(UserDict[str, RegistryEntry]): """Container for entity registry items, maps entity_id -> entry. Maintains two additional indexes: From 7fe78fe9e43c7ce325ccb867529d53d76ed0ca6c Mon Sep 17 00:00:00 2001 From: Olen Date: Wed, 13 Sep 2023 13:09:57 +0200 Subject: [PATCH 457/640] Add diagnostics to Twinkly (#100146) --- CODEOWNERS | 4 +- .../components/twinkly/diagnostics.py | 40 ++++++++++++++ .../components/twinkly/manifest.json | 2 +- tests/components/twinkly/__init__.py | 7 +-- tests/components/twinkly/conftest.py | 54 +++++++++++++++++++ .../twinkly/snapshots/test_diagnostics.ambr | 43 +++++++++++++++ tests/components/twinkly/test_config_flow.py | 12 ++--- tests/components/twinkly/test_diagnostics.py | 28 ++++++++++ tests/components/twinkly/test_light.py | 10 ++-- 9 files changed, 183 insertions(+), 17 deletions(-) create mode 100644 homeassistant/components/twinkly/diagnostics.py create mode 100644 tests/components/twinkly/conftest.py create mode 100644 tests/components/twinkly/snapshots/test_diagnostics.ambr create mode 100644 tests/components/twinkly/test_diagnostics.py diff --git a/CODEOWNERS b/CODEOWNERS index bba1c2debbffa7..3aefaabb50b676 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1324,8 +1324,8 @@ build.json @home-assistant/supervisor /tests/components/tuya/ @Tuya @zlinoliver @frenck /homeassistant/components/twentemilieu/ @frenck /tests/components/twentemilieu/ @frenck -/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 -/tests/components/twinkly/ @dr1rrb @Robbie1221 +/homeassistant/components/twinkly/ @dr1rrb @Robbie1221 @Olen +/tests/components/twinkly/ @dr1rrb @Robbie1221 @Olen /homeassistant/components/twitch/ @joostlek /tests/components/twitch/ @joostlek /homeassistant/components/ukraine_alarm/ @PaulAnnekov diff --git a/homeassistant/components/twinkly/diagnostics.py b/homeassistant/components/twinkly/diagnostics.py new file mode 100644 index 00000000000000..06afba5782bd2d --- /dev/null +++ b/homeassistant/components/twinkly/diagnostics.py @@ -0,0 +1,40 @@ +"""Diagnostics support for Twinkly.""" +from __future__ import annotations + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_IP_ADDRESS, CONF_MAC +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import DATA_DEVICE_INFO, DOMAIN + +TO_REDACT = [CONF_HOST, CONF_IP_ADDRESS, CONF_MAC] + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a Twinkly config entry.""" + attributes = None + state = None + entity_registry = er.async_get(hass) + + entity_id = entity_registry.async_get_entity_id( + LIGHT_DOMAIN, DOMAIN, str(entry.unique_id) + ) + if entity_id: + state = hass.states.get(entity_id) + if state: + attributes = state.attributes + return async_redact_data( + { + "entry": entry.as_dict(), + "device_info": hass.data[DOMAIN][entry.entry_id][DATA_DEVICE_INFO], + "attributes": attributes, + }, + TO_REDACT, + ) diff --git a/homeassistant/components/twinkly/manifest.json b/homeassistant/components/twinkly/manifest.json index 59deff915c391b..c6ab0bab893ad5 100644 --- a/homeassistant/components/twinkly/manifest.json +++ b/homeassistant/components/twinkly/manifest.json @@ -1,7 +1,7 @@ { "domain": "twinkly", "name": "Twinkly", - "codeowners": ["@dr1rrb", "@Robbie1221"], + "codeowners": ["@dr1rrb", "@Robbie1221", "@Olen"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/twinkly/__init__.py b/tests/components/twinkly/__init__.py index 31d1eff2a61ff6..0780bc0126f871 100644 --- a/tests/components/twinkly/__init__.py +++ b/tests/components/twinkly/__init__.py @@ -1,6 +1,5 @@ """Constants and mock for the twkinly component tests.""" -from uuid import uuid4 from aiohttp.client_exceptions import ClientConnectionError @@ -8,6 +7,7 @@ TEST_HOST = "test.twinkly.com" TEST_ID = "twinkly_test_device_id" +TEST_UID = "4c8fccf5-e08a-4173-92d5-49bf479252a2" TEST_NAME = "twinkly_test_device_name" TEST_NAME_ORIGINAL = "twinkly_test_original_device_name" # the original (deprecated) name stored in the conf TEST_MODEL = "twinkly_test_device_model" @@ -28,11 +28,12 @@ def __init__(self) -> None: self.mode = None self.version = "2.8.10" - self.id = str(uuid4()) + self.id = TEST_UID self.device_info = { "uuid": self.id, - "device_name": self.id, # we make sure that entity id is different for each test + "device_name": TEST_NAME, "product_code": TEST_MODEL, + "sw_version": self.version, } @property diff --git a/tests/components/twinkly/conftest.py b/tests/components/twinkly/conftest.py new file mode 100644 index 00000000000000..5a689c31baaca3 --- /dev/null +++ b/tests/components/twinkly/conftest.py @@ -0,0 +1,54 @@ +"""Configure tests for the Twinkly integration.""" +from collections.abc import Awaitable, Callable, Coroutine +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import TEST_MODEL, TEST_NAME, TEST_UID, ClientMock + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" +TITLE = "Twinkly" + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create Twinkly entry in Home Assistant.""" + client = ClientMock() + return MockConfigEntry( + domain=DOMAIN, + title=TITLE, + unique_id=TEST_UID, + entry_id=TEST_UID, + data={ + "host": client.host, + "id": client.id, + "name": TEST_NAME, + "model": TEST_MODEL, + "device_name": TEST_NAME, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> Callable[[], Coroutine[Any, Any, ClientMock]]: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + async def func() -> ClientMock: + mock = ClientMock() + with patch("homeassistant.components.twinkly.Twinkly", return_value=mock): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + return mock + + return func diff --git a/tests/components/twinkly/snapshots/test_diagnostics.ambr b/tests/components/twinkly/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..c5788444845593 --- /dev/null +++ b/tests/components/twinkly/snapshots/test_diagnostics.ambr @@ -0,0 +1,43 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'attributes': dict({ + 'brightness': 26, + 'color_mode': 'brightness', + 'effect_list': list([ + ]), + 'friendly_name': 'twinkly_test_device_name', + 'icon': 'mdi:string-lights', + 'supported_color_modes': list([ + 'brightness', + ]), + 'supported_features': 4, + }), + 'device_info': dict({ + 'device_name': 'twinkly_test_device_name', + 'product_code': 'twinkly_test_device_model', + 'sw_version': '2.8.10', + 'uuid': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + }), + 'entry': dict({ + 'data': dict({ + 'device_name': 'twinkly_test_device_name', + 'host': '**REDACTED**', + 'id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'model': 'twinkly_test_device_model', + 'name': 'twinkly_test_device_name', + }), + 'disabled_by': None, + 'domain': 'twinkly', + 'entry_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'options': dict({ + }), + 'pref_disable_new_entities': False, + 'pref_disable_polling': False, + 'source': 'user', + 'title': 'Twinkly', + 'unique_id': '4c8fccf5-e08a-4173-92d5-49bf479252a2', + 'version': 1, + }), + }) +# --- diff --git a/tests/components/twinkly/test_config_flow.py b/tests/components/twinkly/test_config_flow.py index 1219130c1976f8..2d335c69923142 100644 --- a/tests/components/twinkly/test_config_flow.py +++ b/tests/components/twinkly/test_config_flow.py @@ -12,7 +12,7 @@ from homeassistant.const import CONF_MODEL from homeassistant.core import HomeAssistant -from . import TEST_MODEL, ClientMock +from . import TEST_MODEL, TEST_NAME, ClientMock from tests.common import MockConfigEntry @@ -60,11 +60,11 @@ async def test_success_flow(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "dummy", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -113,11 +113,11 @@ async def test_dhcp_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] == "create_entry" - assert result["title"] == client.id + assert result["title"] == TEST_NAME assert result["data"] == { CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, } @@ -131,7 +131,7 @@ async def test_dhcp_already_exists(hass: HomeAssistant) -> None: data={ CONF_HOST: "1.2.3.4", CONF_ID: client.id, - CONF_NAME: client.id, + CONF_NAME: TEST_NAME, CONF_MODEL: TEST_MODEL, }, unique_id=client.id, diff --git a/tests/components/twinkly/test_diagnostics.py b/tests/components/twinkly/test_diagnostics.py new file mode 100644 index 00000000000000..ab07cabef4ab1d --- /dev/null +++ b/tests/components/twinkly/test_diagnostics.py @@ -0,0 +1,28 @@ +"""Tests for the diagnostics of the twinkly component.""" +from collections.abc import Awaitable, Callable + +from syrupy import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import ClientMock + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + +ComponentSetup = Callable[[], Awaitable[ClientMock]] + +DOMAIN = "twinkly" + + +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + setup_integration: ComponentSetup, + snapshot: SnapshotAssertion, +) -> None: + """Test diagnostics.""" + await setup_integration() + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await get_diagnostics_for_config_entry(hass, hass_client, entry) == snapshot diff --git a/tests/components/twinkly/test_light.py b/tests/components/twinkly/test_light.py index f66c82dc2eda34..bcb40f22d08a27 100644 --- a/tests/components/twinkly/test_light.py +++ b/tests/components/twinkly/test_light.py @@ -16,7 +16,7 @@ from homeassistant.helpers.device_registry import DeviceEntry from homeassistant.helpers.entity_registry import RegistryEntry -from . import TEST_MODEL, TEST_NAME_ORIGINAL, ClientMock +from . import TEST_MODEL, TEST_NAME, TEST_NAME_ORIGINAL, ClientMock from tests.common import MockConfigEntry @@ -28,16 +28,16 @@ async def test_initial_state(hass: HomeAssistant) -> None: state = hass.states.get(entity.entity_id) # Basic state properties - assert state.name == entity.unique_id + assert state.name == TEST_NAME assert state.state == "on" assert state.attributes[ATTR_BRIGHTNESS] == 26 - assert state.attributes["friendly_name"] == entity.unique_id + assert state.attributes["friendly_name"] == TEST_NAME assert state.attributes["icon"] == "mdi:string-lights" - assert entity.original_name == entity.unique_id + assert entity.original_name == TEST_NAME assert entity.original_icon == "mdi:string-lights" - assert device.name == entity.unique_id + assert device.name == TEST_NAME assert device.model == TEST_MODEL assert device.manufacturer == "LEDWORKS" From 705ee3032b68f51459f10736d17360810a3479fa Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:13:27 +0200 Subject: [PATCH 458/640] Use shorthanded attrs for yamaha_musiccast select (#100273) --- .../components/yamaha_musiccast/select.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/yamaha_musiccast/select.py b/homeassistant/components/yamaha_musiccast/select.py index a8ca6162c91aaf..8ef9df1ba2f9df 100644 --- a/homeassistant/components/yamaha_musiccast/select.py +++ b/homeassistant/components/yamaha_musiccast/select.py @@ -24,37 +24,39 @@ async def async_setup_entry( for capability in coordinator.data.capabilities: if isinstance(capability, OptionSetter): - select_entities.append(SelectableCapapility(coordinator, capability)) + select_entities.append(SelectableCapability(coordinator, capability)) for zone, data in coordinator.data.zones.items(): for capability in data.capabilities: if isinstance(capability, OptionSetter): select_entities.append( - SelectableCapapility(coordinator, capability, zone) + SelectableCapability(coordinator, capability, zone) ) async_add_entities(select_entities) -class SelectableCapapility(MusicCastCapabilityEntity, SelectEntity): +class SelectableCapability(MusicCastCapabilityEntity, SelectEntity): """Representation of a MusicCast Select entity.""" capability: OptionSetter + def __init__( + self, + coordinator: MusicCastDataUpdateCoordinator, + capability: OptionSetter, + zone_id: str | None = None, + ) -> None: + """Initialize the MusicCast Select entity.""" + MusicCastCapabilityEntity.__init__(self, coordinator, capability, zone_id) + self._attr_options = list(capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(capability.id) + async def async_select_option(self, option: str) -> None: """Select the given option.""" value = {val: key for key, val in self.capability.options.items()}[option] await self.capability.set(value) - - @property - def translation_key(self) -> str | None: - """Return the translation key to translate the entity's states.""" - return TRANSLATION_KEY_MAPPING.get(self.capability.id) - - @property - def options(self) -> list[str]: - """Return the list possible options.""" - return list(self.capability.options.values()) + self._attr_translation_key = TRANSLATION_KEY_MAPPING.get(self.capability.id) @property def current_option(self) -> str | None: From 1f1411b6a545e3204d766faea222a130dea8b800 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 13:22:37 +0200 Subject: [PATCH 459/640] Move sms coordinators to their own file (#100276) --- homeassistant/components/sms/__init__.py | 52 +------------------ homeassistant/components/sms/coordinator.py | 56 +++++++++++++++++++++ 2 files changed, 57 insertions(+), 51 deletions(-) create mode 100644 homeassistant/components/sms/coordinator.py diff --git a/homeassistant/components/sms/__init__.py b/homeassistant/components/sms/__init__.py index 824a95e36b12c5..a606b83896f1f9 100644 --- a/homeassistant/components/sms/__init__.py +++ b/homeassistant/components/sms/__init__.py @@ -1,9 +1,6 @@ """The sms component.""" -import asyncio -from datetime import timedelta import logging -import gammu import voluptuous as vol from homeassistant.config_entries import ConfigEntry @@ -12,12 +9,10 @@ from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import config_validation as cv, discovery from homeassistant.helpers.typing import ConfigType -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import ( CONF_BAUD_SPEED, DEFAULT_BAUD_SPEED, - DEFAULT_SCAN_INTERVAL, DOMAIN, GATEWAY, HASS_CONFIG, @@ -25,6 +20,7 @@ SIGNAL_COORDINATOR, SMS_GATEWAY, ) +from .coordinator import NetworkCoordinator, SignalCoordinator from .gateway import create_sms_gateway _LOGGER = logging.getLogger(__name__) @@ -45,8 +41,6 @@ extra=vol.ALLOW_EXTRA, ) -_LOGGER = logging.getLogger(__name__) - async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Configure Gammu state machine.""" @@ -107,47 +101,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await gateway.terminate_async() return unload_ok - - -class SignalCoordinator(DataUpdateCoordinator): - """Signal strength coordinator.""" - - def __init__(self, hass, gateway): - """Initialize signal strength coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device signal state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device signal quality.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_signal_quality_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc - - -class NetworkCoordinator(DataUpdateCoordinator): - """Network info coordinator.""" - - def __init__(self, hass, gateway): - """Initialize network info coordinator.""" - super().__init__( - hass, - _LOGGER, - name="Device network state", - update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), - ) - self._gateway = gateway - - async def _async_update_data(self): - """Fetch device network info.""" - try: - async with asyncio.timeout(10): - return await self._gateway.get_network_info_async() - except gammu.GSMError as exc: - raise UpdateFailed(f"Error communicating with device: {exc}") from exc diff --git a/homeassistant/components/sms/coordinator.py b/homeassistant/components/sms/coordinator.py new file mode 100644 index 00000000000000..fd212fce4f2f11 --- /dev/null +++ b/homeassistant/components/sms/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinators for the sms integration.""" +import asyncio +from datetime import timedelta +import logging + +import gammu + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_SCAN_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class SignalCoordinator(DataUpdateCoordinator): + """Signal strength coordinator.""" + + def __init__(self, hass, gateway): + """Initialize signal strength coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device signal state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device signal quality.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_signal_quality_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc + + +class NetworkCoordinator(DataUpdateCoordinator): + """Network info coordinator.""" + + def __init__(self, hass, gateway): + """Initialize network info coordinator.""" + super().__init__( + hass, + _LOGGER, + name="Device network state", + update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL), + ) + self._gateway = gateway + + async def _async_update_data(self): + """Fetch device network info.""" + try: + async with asyncio.timeout(10): + return await self._gateway.get_network_info_async() + except gammu.GSMError as exc: + raise UpdateFailed(f"Error communicating with device: {exc}") from exc From 958b9237836ed96e066b4fc84a6f8287aeda4c22 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Wed, 13 Sep 2023 13:29:20 +0200 Subject: [PATCH 460/640] Limit waze_travel_time to 1 call every 0.5s (#100191) --- homeassistant/components/waze_travel_time/sensor.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/waze_travel_time/sensor.py b/homeassistant/components/waze_travel_time/sensor.py index 2b3010a39cb0c7..bf3544de8a9037 100644 --- a/homeassistant/components/waze_travel_time/sensor.py +++ b/homeassistant/components/waze_travel_time/sensor.py @@ -1,6 +1,7 @@ """Support for Waze travel time sensor.""" from __future__ import annotations +import asyncio from datetime import timedelta import logging from typing import Any @@ -48,6 +49,10 @@ SCAN_INTERVAL = timedelta(minutes=5) +PARALLEL_UPDATES = 1 + +MS_BETWEEN_API_CALLS = 0.5 + async def async_setup_entry( hass: HomeAssistant, @@ -144,6 +149,7 @@ async def async_update(self) -> None: 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) class WazeTravelTimeData: From 9f3b1a8d44cd3ea2a5f928e199ae842e6b25b4fd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:32:00 +0200 Subject: [PATCH 461/640] Use hass.loop.create_future() in zha (#100056) * Use hass.loop.create_future() in zha * Remove not needed method --- homeassistant/components/zha/entity.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 5722d91116ab59..da34b8299078e9 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -59,7 +59,6 @@ def __init__(self, unique_id: str, zha_device: ZHADevice, **kwargs: Any) -> None self._extra_state_attributes: dict[str, Any] = {} self._zha_device = zha_device self._unsubs: list[Callable[[], None]] = [] - self.remove_future: asyncio.Future[Any] = asyncio.Future() @property def unique_id(self) -> str: @@ -143,6 +142,8 @@ def log(self, level: int, msg: str, *args, **kwargs): class ZhaEntity(BaseZhaEntity, RestoreEntity): """A base class for non group ZHA entities.""" + remove_future: asyncio.Future[Any] + def __init_subclass__(cls, id_suffix: str | None = None, **kwargs: Any) -> None: """Initialize subclass. @@ -188,7 +189,7 @@ def available(self) -> bool: async def async_added_to_hass(self) -> None: """Run when about to be added to hass.""" - self.remove_future = asyncio.Future() + self.remove_future = self.hass.loop.create_future() self.async_accept_signal( None, f"{SIGNAL_REMOVE}_{self.zha_device.ieee}", From d638efdcfcf240eb2903cf0032681739ebc1ffe8 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 13:50:00 +0200 Subject: [PATCH 462/640] Use shorthanded attrs for vera sensor (#100269) Co-authored-by: Franck Nijhof --- homeassistant/components/vera/sensor.py | 45 +++++++++---------------- 1 file changed, 16 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/vera/sensor.py b/homeassistant/components/vera/sensor.py index 942ebc77acdbab..58e350bd034472 100644 --- a/homeassistant/components/vera/sensor.py +++ b/homeassistant/components/vera/sensor.py @@ -55,35 +55,22 @@ def __init__( self.last_changed_time = None VeraDevice.__init__(self, vera_device, controller_data) self.entity_id = ENTITY_ID_FORMAT.format(self.vera_id) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this entity.""" - if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return SensorDeviceClass.TEMPERATURE - if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return SensorDeviceClass.ILLUMINANCE - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return SensorDeviceClass.HUMIDITY - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return SensorDeviceClass.POWER - return None - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of this entity, if any.""" - if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: - return self._temperature_units + self._attr_device_class = SensorDeviceClass.TEMPERATURE + elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: + self._attr_device_class = SensorDeviceClass.ILLUMINANCE + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_device_class = SensorDeviceClass.HUMIDITY + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_device_class = SensorDeviceClass.POWER if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: - return LIGHT_LUX - if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: - return "level" - if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: - return PERCENTAGE - if self.vera_device.category == veraApi.CATEGORY_POWER_METER: - return UnitOfPower.WATT - return None + self._attr_native_unit_of_measurement = LIGHT_LUX + elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + self._attr_native_unit_of_measurement = "level" + elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + self._attr_native_unit_of_measurement = PERCENTAGE + elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + self._attr_native_unit_of_measurement = UnitOfPower.WATT def update(self) -> None: """Update the state.""" @@ -94,9 +81,9 @@ def update(self) -> None: vera_temp_units = self.vera_device.vera_controller.temperature_units if vera_temp_units == "F": - self._temperature_units = UnitOfTemperature.FAHRENHEIT + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT else: - self._temperature_units = UnitOfTemperature.CELSIUS + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: self._attr_native_value = self.vera_device.light From 38e013a90e3c0c6a3a739ff60efb291b969c8827 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 14:15:40 +0200 Subject: [PATCH 463/640] Remove NZBGet configurable scan interval (#98869) --- homeassistant/components/nzbget/__init__.py | 12 +---- .../components/nzbget/config_flow.py | 45 +------------------ homeassistant/components/nzbget/const.py | 1 - .../components/nzbget/coordinator.py | 9 +--- homeassistant/components/nzbget/strings.json | 9 ---- tests/components/nzbget/test_config_flow.py | 32 +------------ 6 files changed, 5 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/nzbget/__init__.py b/homeassistant/components/nzbget/__init__.py index c3b6aab619bae0..9d6fafd30c7c4f 100644 --- a/homeassistant/components/nzbget/__init__.py +++ b/homeassistant/components/nzbget/__init__.py @@ -2,7 +2,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_SCAN_INTERVAL, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo @@ -12,7 +12,6 @@ ATTR_SPEED, DATA_COORDINATOR, DATA_UNDO_UPDATE_LISTENER, - DEFAULT_SCAN_INTERVAL, DEFAULT_SPEED_LIMIT, DOMAIN, SERVICE_PAUSE, @@ -34,18 +33,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up NZBGet from a config entry.""" hass.data.setdefault(DOMAIN, {}) - if not entry.options: - options = { - CONF_SCAN_INTERVAL: entry.data.get( - CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL - ), - } - hass.config_entries.async_update_entry(entry, options=options) - coordinator = NZBGetDataUpdateCoordinator( hass, config=entry.data, - options=entry.options, ) await coordinator.async_config_entry_first_refresh() diff --git a/homeassistant/components/nzbget/config_flow.py b/homeassistant/components/nzbget/config_flow.py index 732ef87976248f..782ec791eebeec 100644 --- a/homeassistant/components/nzbget/config_flow.py +++ b/homeassistant/components/nzbget/config_flow.py @@ -6,28 +6,19 @@ import voluptuous as vol -from homeassistant.config_entries import ConfigEntry, ConfigFlow, OptionsFlow +from homeassistant.config_entries import ConfigFlow from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, ) -from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult -from .const import ( - DEFAULT_NAME, - DEFAULT_PORT, - DEFAULT_SCAN_INTERVAL, - DEFAULT_SSL, - DEFAULT_VERIFY_SSL, - DOMAIN, -) +from .const import DEFAULT_NAME, DEFAULT_PORT, DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN from .coordinator import NZBGetAPI, NZBGetAPIException _LOGGER = logging.getLogger(__name__) @@ -55,12 +46,6 @@ class NZBGetConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 - @staticmethod - @callback - def async_get_options_flow(config_entry: ConfigEntry) -> NZBGetOptionsFlowHandler: - """Get the options flow for this handler.""" - return NZBGetOptionsFlowHandler(config_entry) - async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> FlowResult: @@ -106,29 +91,3 @@ async def async_step_user( data_schema=vol.Schema(data_schema), errors=errors or {}, ) - - -class NZBGetOptionsFlowHandler(OptionsFlow): - """Handle NZBGet client options.""" - - def __init__(self, config_entry: ConfigEntry) -> None: - """Initialize options flow.""" - self.config_entry = config_entry - - async def async_step_init( - self, user_input: dict[str, Any] | None = None - ) -> FlowResult: - """Manage NZBGet options.""" - if user_input is not None: - 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, - } - - return self.async_show_form(step_id="init", data_schema=vol.Schema(options)) diff --git a/homeassistant/components/nzbget/const.py b/homeassistant/components/nzbget/const.py index 928487738eb8a0..7838d64c6d78e9 100644 --- a/homeassistant/components/nzbget/const.py +++ b/homeassistant/components/nzbget/const.py @@ -11,7 +11,6 @@ # Defaults DEFAULT_NAME = "NZBGet" DEFAULT_PORT = 6789 -DEFAULT_SCAN_INTERVAL = 5 # time in seconds DEFAULT_SPEED_LIMIT = 1000 # 1 Megabyte/Sec DEFAULT_SSL = False DEFAULT_VERIFY_SSL = False diff --git a/homeassistant/components/nzbget/coordinator.py b/homeassistant/components/nzbget/coordinator.py index 7326fa50dd54ab..dcefe25eae95cf 100644 --- a/homeassistant/components/nzbget/coordinator.py +++ b/homeassistant/components/nzbget/coordinator.py @@ -11,7 +11,6 @@ CONF_HOST, CONF_PASSWORD, CONF_PORT, - CONF_SCAN_INTERVAL, CONF_SSL, CONF_USERNAME, CONF_VERIFY_SSL, @@ -32,7 +31,6 @@ def __init__( hass: HomeAssistant, *, config: Mapping[str, Any], - options: Mapping[str, Any], ) -> None: """Initialize global NZBGet data updater.""" self.nzbget = NZBGetAPI( @@ -47,13 +45,8 @@ def __init__( self._completed_downloads_init = False self._completed_downloads = set[tuple]() - update_interval = timedelta(seconds=options[CONF_SCAN_INTERVAL]) - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=update_interval, + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(seconds=5) ) def _check_completed_downloads(self, history): diff --git a/homeassistant/components/nzbget/strings.json b/homeassistant/components/nzbget/strings.json index a1faa63bb3958f..4da9a0b505ede3 100644 --- a/homeassistant/components/nzbget/strings.json +++ b/homeassistant/components/nzbget/strings.json @@ -23,15 +23,6 @@ "unknown": "[%key:common::config_flow::error::unknown%]" } }, - "options": { - "step": { - "init": { - "data": { - "scan_interval": "Update frequency (seconds)" - } - } - } - }, "entity": { "sensor": { "article_cache": { diff --git a/tests/components/nzbget/test_config_flow.py b/tests/components/nzbget/test_config_flow.py index c078a6523bc1ab..e26be8b9880cfd 100644 --- a/tests/components/nzbget/test_config_flow.py +++ b/tests/components/nzbget/test_config_flow.py @@ -5,7 +5,7 @@ from homeassistant.components.nzbget.const import DOMAIN from homeassistant.config_entries import SOURCE_USER -from homeassistant.const import CONF_SCAN_INTERVAL, CONF_VERIFY_SSL +from homeassistant.const import CONF_VERIFY_SSL from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -122,33 +122,3 @@ async def test_user_form_single_instance_allowed(hass: HomeAssistant) -> None: ) assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" - - -async def test_options_flow(hass: HomeAssistant, nzbget_api) -> None: - """Test updating options.""" - entry = MockConfigEntry( - domain=DOMAIN, - data=ENTRY_CONFIG, - options={CONF_SCAN_INTERVAL: 5}, - ) - entry.add_to_hass(hass) - - with patch("homeassistant.components.nzbget.PLATFORMS", []): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.options[CONF_SCAN_INTERVAL] == 5 - - result = await hass.config_entries.options.async_init(entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - with _patch_async_setup_entry(): - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={CONF_SCAN_INTERVAL: 15}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"][CONF_SCAN_INTERVAL] == 15 From afa015226134f57e1f7b610d9077cba2b5e6de42 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 14:40:01 +0200 Subject: [PATCH 464/640] Update syrupy to 4.5.0 (#100283) --- requirements_test.txt | 2 +- tests/syrupy.py | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/requirements_test.txt b/requirements_test.txt index ba636c566490e4..8da4e92c81d1df 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -31,7 +31,7 @@ pytest-xdist==3.3.1 pytest==7.3.1 requests_mock==1.11.0 respx==0.20.2 -syrupy==4.2.1 +syrupy==4.5.0 tqdm==4.66.1 types-aiofiles==22.1.0 types-atomicwrites==1.4.5.1 diff --git a/tests/syrupy.py b/tests/syrupy.py index 9433eb1649c47e..c7d114a481266e 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -85,6 +85,7 @@ def _serialize( *, depth: int = 0, exclude: PropertyFilter | None = None, + include: PropertyFilter | None = None, matcher: PropertyMatcher | None = None, path: PropertyPath = (), visited: set[Any] | None = None, @@ -125,6 +126,7 @@ def _serialize( serializable_data, depth=depth, exclude=exclude, + include=include, matcher=matcher, path=path, visited=visited, From 65c9e5ee13c35fd1ed8179ae140945e06d2c76f0 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 14:40:27 +0200 Subject: [PATCH 465/640] Update mutagen to 1.47.0 (#100284) --- homeassistant/components/tts/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/tts/manifest.json b/homeassistant/components/tts/manifest.json index 249e427c59132d..f1120ed2750046 100644 --- a/homeassistant/components/tts/manifest.json +++ b/homeassistant/components/tts/manifest.json @@ -8,5 +8,5 @@ "integration_type": "entity", "loggers": ["mutagen"], "quality_scale": "internal", - "requirements": ["mutagen==1.46.0"] + "requirements": ["mutagen==1.47.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index bd6a130a2fc022..ed972c39c2c232 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -29,7 +29,7 @@ ifaddr==0.2.0 janus==1.0.0 Jinja2==3.1.2 lru-dict==1.2.0 -mutagen==1.46.0 +mutagen==1.47.0 orjson==3.9.7 packaging>=23.1 paho-mqtt==1.6.1 diff --git a/requirements_all.txt b/requirements_all.txt index 9f957e886d7bba..c27c11b6a3e817 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index eaeaa7496a834e..aceab8ce5a5c53 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -961,7 +961,7 @@ motioneye-client==0.3.14 mullvad-api==1.0.0 # homeassistant.components.tts -mutagen==1.46.0 +mutagen==1.47.0 # homeassistant.components.mutesync mutesync==0.0.1 From d44db6ee6887617b5f34a09bd2735fc23c6997e0 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 15:10:35 +0200 Subject: [PATCH 466/640] Use shorthand attrs for xbox base_sensor (#100290) --- homeassistant/components/xbox/base_sensor.py | 30 ++++++-------------- 1 file changed, 9 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/xbox/base_sensor.py b/homeassistant/components/xbox/base_sensor.py index ffbbee8637d6bf..9aecb100df0a62 100644 --- a/homeassistant/components/xbox/base_sensor.py +++ b/homeassistant/components/xbox/base_sensor.py @@ -20,11 +20,15 @@ def __init__( super().__init__(coordinator) self.xuid = xuid self.attribute = attribute - - @property - def unique_id(self) -> str: - """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self.xuid}_{self.attribute}" + self._attr_unique_id = f"{xuid}_{attribute}" + self._attr_entity_registry_enabled_default = attribute == "online" + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, "xbox_live")}, + manufacturer="Microsoft", + model="Xbox Live", + name="Xbox Live", + ) @property def data(self) -> PresenceData | None: @@ -61,19 +65,3 @@ def entity_picture(self) -> str | None: query = dict(url.query) query.pop("mode", None) return str(url.with_query(query)) - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.attribute == "online" - - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, "xbox_live")}, - manufacturer="Microsoft", - model="Xbox Live", - name="Xbox Live", - ) From 80aa19263b28996762ec1e36abb46aac9ff01d80 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Wed, 13 Sep 2023 15:32:03 +0200 Subject: [PATCH 467/640] Netgear catch no info error (#100212) --- .../components/netgear/config_flow.py | 6 +- homeassistant/components/netgear/strings.json | 3 +- tests/components/netgear/test_config_flow.py | 64 ++++++++----------- 3 files changed, 32 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/netgear/config_flow.py b/homeassistant/components/netgear/config_flow.py index da260a2559ed96..7b74880d011dbf 100644 --- a/homeassistant/components/netgear/config_flow.py +++ b/homeassistant/components/netgear/config_flow.py @@ -190,8 +190,6 @@ async def async_step_user(self, user_input=None): ) except CannotLoginException: errors["base"] = "config" - - if errors: return await self._show_setup_form(user_input, errors) config_data = { @@ -204,6 +202,10 @@ async def async_step_user(self, user_input=None): # Check if already configured info = await self.hass.async_add_executor_job(api.get_info) + if info is None: + errors["base"] = "info" + return await self._show_setup_form(user_input, errors) + await self.async_set_unique_id(info["SerialNumber"], raise_on_progress=False) self._abort_if_unique_id_configured(updates=config_data) diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index f2af3dd7804a99..a903535d5a8780 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -11,7 +11,8 @@ } }, "error": { - "config": "Connection or login error: please check your configuration" + "config": "Connection or login error: please check your configuration", + "info": "Failed to get info from router" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", diff --git a/tests/components/netgear/test_config_flow.py b/tests/components/netgear/test_config_flow.py index 248ad3a69ea7b8..37787024fb6d06 100644 --- a/tests/components/netgear/test_config_flow.py +++ b/tests/components/netgear/test_config_flow.py @@ -76,41 +76,6 @@ def mock_controller_service(): yield service_mock -@pytest.fixture(name="service_5555") -def mock_controller_service_5555(): - """Mock a successful service.""" - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=ROUTER_INFOS) - service_mock.return_value.port = 5555 - service_mock.return_value.ssl = True - yield service_mock - - -@pytest.fixture(name="service_incomplete") -def mock_controller_service_incomplete(): - """Mock a successful service.""" - router_infos = ROUTER_INFOS.copy() - router_infos.pop("DeviceName") - with patch( - "homeassistant.components.netgear.async_setup_entry", return_value=True - ), patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.get_info = Mock(return_value=router_infos) - service_mock.return_value.port = 80 - service_mock.return_value.ssl = False - yield service_mock - - -@pytest.fixture(name="service_failed") -def mock_controller_service_failed(): - """Mock a failed service.""" - with patch("homeassistant.components.netgear.router.Netgear") as service_mock: - service_mock.return_value.login_try_port = Mock(return_value=None) - service_mock.return_value.get_info = Mock(return_value=None) - yield service_mock - - async def test_user(hass: HomeAssistant, service) -> None: """Test user step.""" result = await hass.config_entries.flow.async_init( @@ -138,7 +103,7 @@ async def test_user(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: +async def test_user_connect_error(hass: HomeAssistant, service) -> None: """Test user step with connection failure.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -146,7 +111,23 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.get_info = Mock(return_value=None) + # Have to provide all config + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_HOST: HOST, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": "info"} + + service.return_value.login_try_port = Mock(return_value=None) + result = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -160,7 +141,7 @@ async def test_user_connect_error(hass: HomeAssistant, service_failed) -> None: assert result["errors"] == {"base": "config"} -async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> None: +async def test_user_incomplete_info(hass: HomeAssistant, service) -> None: """Test user step with incomplete device info.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} @@ -168,6 +149,10 @@ async def test_user_incomplete_info(hass: HomeAssistant, service_incomplete) -> assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + router_infos = ROUTER_INFOS.copy() + router_infos.pop("DeviceName") + service.return_value.get_info = Mock(return_value=router_infos) + # Have to provide all config result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -313,7 +298,7 @@ async def test_ssdp(hass: HomeAssistant, service) -> None: assert result["data"][CONF_PASSWORD] == PASSWORD -async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: +async def test_ssdp_port_5555(hass: HomeAssistant, service) -> None: """Test ssdp step with port 5555.""" result = await hass.config_entries.flow.async_init( DOMAIN, @@ -332,6 +317,9 @@ async def test_ssdp_port_5555(hass: HomeAssistant, service_5555) -> None: assert result["type"] == data_entry_flow.FlowResultType.FORM assert result["step_id"] == "user" + service.return_value.port = 5555 + service.return_value.ssl = True + result = await hass.config_entries.flow.async_configure( result["flow_id"], {CONF_PASSWORD: PASSWORD} ) From 8498cdfb3c49adb2e02429d8d62c12365fd45e05 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 15:49:36 +0200 Subject: [PATCH 468/640] Remove profile from Withings config flow (#100202) * Remove profile from Withings config flow * Add config flow migration * Add config flow migration * Remove datamanager profile * Remove datamanager profile * Add manufacturer * Remove migration * Remove migration * Fix feedback --- homeassistant/components/withings/__init__.py | 42 ++-- homeassistant/components/withings/common.py | 16 +- .../components/withings/config_flow.py | 93 +++---- homeassistant/components/withings/const.py | 1 + homeassistant/components/withings/entity.py | 3 +- tests/components/withings/__init__.py | 15 ++ tests/components/withings/conftest.py | 4 +- .../components/withings/test_binary_sensor.py | 3 +- tests/components/withings/test_common.py | 97 -------- tests/components/withings/test_config_flow.py | 124 +++++++--- tests/components/withings/test_init.py | 228 +++++++----------- 11 files changed, 254 insertions(+), 372 deletions(-) delete mode 100644 tests/components/withings/test_common.py diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 682efde88816c7..841c9da3c702f2 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -5,7 +5,6 @@ from __future__ import annotations import asyncio -from typing import Any from aiohttp.web import Request, Response import voluptuous as vol @@ -17,12 +16,14 @@ async_import_client_credential, ) from homeassistant.components.webhook import ( + async_generate_id, async_unregister as async_unregister_webhook, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, + CONF_TOKEN, CONF_WEBHOOK_ID, Platform, ) @@ -39,6 +40,7 @@ get_data_manager_by_webhook_id, json_message_response, ) +from .const import CONF_USE_WEBHOOK, CONFIG DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -103,33 +105,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Withings from a config entry.""" - config_updates: dict[str, Any] = {} - - # Add a unique id if it's an older config entry. - if entry.unique_id != entry.data["token"]["userid"] or not isinstance( - entry.unique_id, str - ): - config_updates["unique_id"] = str(entry.data["token"]["userid"]) - - # Add the webhook configuration. - if CONF_WEBHOOK_ID not in entry.data: - webhook_id = webhook.async_generate_id() - config_updates["data"] = { - **entry.data, - **{ - const.CONF_USE_WEBHOOK: hass.data[DOMAIN][const.CONFIG][ - const.CONF_USE_WEBHOOK - ], - CONF_WEBHOOK_ID: webhook_id, - }, + if CONF_USE_WEBHOOK not in entry.options: + new_data = entry.data.copy() + new_options = { + CONF_USE_WEBHOOK: new_data.get(CONF_USE_WEBHOOK, False), } + unique_id = str(entry.data[CONF_TOKEN]["userid"]) + if CONF_WEBHOOK_ID not in new_data: + new_data[CONF_WEBHOOK_ID] = async_generate_id() - if config_updates: - hass.config_entries.async_update_entry(entry, **config_updates) + hass.config_entries.async_update_entry( + entry, data=new_data, options=new_options, unique_id=unique_id + ) + use_webhook = hass.data[DOMAIN][CONFIG][CONF_USE_WEBHOOK] + if use_webhook is not None and use_webhook != entry.options[CONF_USE_WEBHOOK]: + new_options = entry.options.copy() + new_options |= {CONF_USE_WEBHOOK: use_webhook} + hass.config_entries.async_update_entry(entry, options=new_options) data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", data_manager.profile) + _LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 3d215567f450e7..98c98f1fa9697c 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -203,7 +203,6 @@ class DataManager: def __init__( self, hass: HomeAssistant, - profile: str, api: ConfigEntryWithingsApi, user_id: int, webhook_config: WebhookConfig, @@ -212,7 +211,6 @@ def __init__( self._hass = hass self._api = api self._user_id = user_id - self._profile = profile self._webhook_config = webhook_config self._notify_subscribe_delay = SUBSCRIBE_DELAY self._notify_unsubscribe_delay = UNSUBSCRIBE_DELAY @@ -256,11 +254,6 @@ def user_id(self) -> int: """Get the user_id of the authenticated user.""" return self._user_id - @property - def profile(self) -> str: - """Get the profile.""" - return self._profile - def async_start_polling_webhook_subscriptions(self) -> None: """Start polling webhook subscriptions (if enabled) to reconcile their setup.""" self.async_stop_polling_webhook_subscriptions() @@ -530,12 +523,11 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - profile: str = config_entry.data[const.PROFILE] - - _LOGGER.debug("Creating withings data manager for profile: %s", profile) + _LOGGER.debug( + "Creating withings data manager for profile: %s", config_entry.title + ) config_entry_data[const.DATA_MANAGER] = DataManager( hass, - profile, ConfigEntryWithingsApi( hass=hass, config_entry=config_entry, @@ -549,7 +541,7 @@ async def async_get_data_manager( url=webhook.async_generate_url( hass, config_entry.data[CONF_WEBHOOK_ID] ), - enabled=config_entry.data[const.CONF_USE_WEBHOOK], + enabled=config_entry.options[const.CONF_USE_WEBHOOK], ), ) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index b0fa1876d92554..7bbf869069f4ce 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,26 +5,24 @@ import logging from typing import Any -import voluptuous as vol from withings_api.common import AuthScope +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 -from homeassistant.util import slugify -from . import const +from .const import CONF_USE_WEBHOOK, DEFAULT_TITLE, DOMAIN class WithingsFlowHandler( - config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=const.DOMAIN + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN ): """Handle a config flow.""" - DOMAIN = const.DOMAIN + DOMAIN = DOMAIN - # Temporarily holds authorization data during the profile step. - _current_data: dict[str, None | str | int] = {} - _reauth_profile: str | None = None + reauth_entry: ConfigEntry | None = None @property def logger(self) -> logging.Logger: @@ -45,64 +43,37 @@ def extra_authorize_data(self) -> dict[str, str]: ) } - async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: - """Override the create entry so user can select a profile.""" - self._current_data = data - return await self.async_step_profile(data) - - async def async_step_profile(self, data: dict[str, Any]) -> FlowResult: - """Prompt the user to select a user profile.""" - errors = {} - profile = data.get(const.PROFILE) or self._reauth_profile - - if profile: - existing_entries = [ - config_entry - for config_entry in self._async_current_entries() - if slugify(config_entry.data.get(const.PROFILE)) == slugify(profile) - ] - - if self._reauth_profile or not existing_entries: - new_data = {**self._current_data, **data, const.PROFILE: profile} - self._current_data = {} - return await self.async_step_finish(new_data) - - errors["base"] = "already_configured" - - return self.async_show_form( - step_id="profile", - data_schema=vol.Schema({vol.Required(const.PROFILE): str}), - errors=errors, + 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"] ) - - async def async_step_reauth(self, data: Mapping[str, Any]) -> FlowResult: - """Prompt user to re-authenticate.""" - self._reauth_profile = data.get(const.PROFILE) return await self.async_step_reauth_confirm() async def async_step_reauth_confirm( - self, data: dict[str, Any] | None = None + self, user_input: dict[str, Any] | None = None ) -> FlowResult: - """Prompt user to re-authenticate.""" - if data is not None: - return await self.async_step_user() - - placeholders = {const.PROFILE: self._reauth_profile} + """Confirm reauth dialog.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() - self.context.update({"title_placeholders": placeholders}) - - return self.async_show_form( - step_id="reauth_confirm", - description_placeholders=placeholders, - ) - - async def async_step_finish(self, data: dict[str, Any]) -> FlowResult: - """Finish the flow.""" - self._current_data = {} + async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: + """Create an entry for the flow, or update existing entry.""" + user_id = str(data[CONF_TOKEN]["userid"]) + if not self.reauth_entry: + await self.async_set_unique_id(user_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=DEFAULT_TITLE, + data=data, + options={CONF_USE_WEBHOOK: False}, + ) - await self.async_set_unique_id( - str(data["token"]["userid"]), raise_on_progress=False - ) - self._abort_if_unique_id_configured(data) + if self.reauth_entry.unique_id == user_id: + 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") - return self.async_create_entry(title=data[const.PROFILE], data=data) + return self.async_abort(reason="wrong_account") diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 02d8977c604ac7..926d29abe5ce55 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,6 +1,7 @@ """Constants used by the Withings component.""" from enum import StrEnum +DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" CONF_USE_WEBHOOK = "use_webhook" diff --git a/homeassistant/components/withings/entity.py b/homeassistant/components/withings/entity.py index a1ad8828b81c98..f17d3ccf03c544 100644 --- a/homeassistant/components/withings/entity.py +++ b/homeassistant/components/withings/entity.py @@ -46,8 +46,7 @@ def __init__( ) self._state_data: Any | None = None self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, str(data_manager.user_id))}, - name=data_manager.profile, + identifiers={(DOMAIN, str(data_manager.user_id))}, manufacturer="Withings" ) @property diff --git a/tests/components/withings/__init__.py b/tests/components/withings/__init__.py index 94c7511054f99d..4634a77a8daf6f 100644 --- a/tests/components/withings/__init__.py +++ b/tests/components/withings/__init__.py @@ -4,8 +4,10 @@ from urllib.parse import urlparse from homeassistant.components.webhook import async_generate_url +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config import async_process_ha_core_config from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -48,3 +50,16 @@ async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) ) await hass.config_entries.async_setup(config_entry.entry_id) + + +async def enable_webhooks(hass: HomeAssistant) -> None: + """Enable webhooks.""" + assert await async_setup_component( + hass, + DOMAIN, + { + DOMAIN: { + CONF_USE_WEBHOOK: True, + } + }, + ) diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index fdd076e2f434fb..a5e51c68c40df6 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -96,9 +96,11 @@ def mock_config_entry(expires_at: int, scopes: list[str]) -> MockConfigEntry: "scope": ",".join(scopes), }, "profile": TITLE, - "use_webhook": True, "webhook_id": WEBHOOK_ID, }, + options={ + "use_webhook": True, + }, ) diff --git a/tests/components/withings/test_binary_sensor.py b/tests/components/withings/test_binary_sensor.py index 6629ba5730be4d..dca9fbc6437c2d 100644 --- a/tests/components/withings/test_binary_sensor.py +++ b/tests/components/withings/test_binary_sensor.py @@ -6,7 +6,7 @@ from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from . import call_webhook, setup_integration +from . import call_webhook, enable_webhooks, setup_integration from .conftest import USER_ID, WEBHOOK_ID from tests.common import MockConfigEntry @@ -21,6 +21,7 @@ async def test_binary_sensor( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test binary sensor.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) client = await hass_client_no_auth() diff --git a/tests/components/withings/test_common.py b/tests/components/withings/test_common.py deleted file mode 100644 index 80f5700d64cfd1..00000000000000 --- a/tests/components/withings/test_common.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Tests for the Withings component.""" -from http import HTTPStatus -import re -from typing import Any -from unittest.mock import MagicMock -from urllib.parse import urlparse - -from aiohttp.test_utils import TestClient -import pytest -import requests_mock -from withings_api.common import NotifyAppli - -from homeassistant.components.withings.common import ConfigEntryWithingsApi -from homeassistant.core import HomeAssistant -from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2Implementation - -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config - -from tests.common import MockConfigEntry -from tests.typing import ClientSessionGenerator - - -async def test_config_entry_withings_api(hass: HomeAssistant) -> None: - """Test ConfigEntryWithingsApi.""" - config_entry = MockConfigEntry( - data={"token": {"access_token": "mock_access_token", "expires_at": 1111111}} - ) - config_entry.add_to_hass(hass) - - implementation_mock = MagicMock(spec=AbstractOAuth2Implementation) - implementation_mock.async_refresh_token.return_value = { - "expires_at": 1111111, - "access_token": "mock_access_token", - } - - with requests_mock.mock() as rqmck: - rqmck.get( - re.compile(".*"), - status_code=HTTPStatus.OK, - json={"status": 0, "body": {"message": "success"}}, - ) - - api = ConfigEntryWithingsApi(hass, config_entry, implementation_mock) - response = await hass.async_add_executor_job( - api.request, "test", {"arg1": "val1", "arg2": "val2"} - ) - assert response == {"message": "success"} - - -@pytest.mark.parametrize( - ("user_id", "arg_user_id", "arg_appli", "expected_code"), - [ - [0, 0, NotifyAppli.WEIGHT.value, 0], # Success - [0, None, 1, 0], # Success, we ignore the user_id. - [0, None, None, 12], # No request body. - [0, "GG", None, 20], # appli not provided. - [0, 0, None, 20], # appli not provided. - [0, 0, 99, 21], # Invalid appli. - [0, 11, NotifyAppli.WEIGHT.value, 0], # Success, we ignore the user_id - ], -) -async def test_webhook_post( - hass: HomeAssistant, - component_factory: ComponentFactory, - aiohttp_client: ClientSessionGenerator, - user_id: int, - arg_user_id: Any, - arg_appli: Any, - expected_code: int, - current_request_with_host: None, -) -> None: - """Test webhook callback.""" - person0 = new_profile_config("person0", user_id) - - await component_factory.configure_component(profile_configs=(person0,)) - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, user_id) - - client: TestClient = await aiohttp_client(hass.http.app) - - post_data = {} - if arg_user_id is not None: - post_data["userid"] = arg_user_id - if arg_appli is not None: - post_data["appli"] = arg_appli - - resp = await client.post( - urlparse(data_manager.webhook_config.url).path, data=post_data - ) - - # Wait for remaining tasks to complete. - await hass.async_block_till_done() - - data = await resp.json() - resp.close() - - assert data["code"] == expected_code diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 360766e028653e..768f6fed16d7d4 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,13 +1,14 @@ """Tests for config flow.""" -from unittest.mock import patch +from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import DOMAIN, PROFILE -from homeassistant.config_entries import SOURCE_USER +from homeassistant.components.withings.const import DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow -from .conftest import CLIENT_ID +from . import setup_integration +from .conftest import CLIENT_ID, USER_ID from tests.common import MockConfigEntry from tests.test_util.aiohttp import AiohttpClientMocker @@ -63,18 +64,12 @@ async def test_full_flow( "homeassistant.components.withings.async_setup_entry", return_value=True ) as mock_setup: result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} - ) assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert len(mock_setup.mock_calls) == 1 assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk" + assert result["title"] == "Withings" assert "result" in result assert result["result"].unique_id == "600" assert "token" in result["result"].data @@ -86,12 +81,13 @@ async def test_config_non_unique_profile( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, current_request_with_host: None, + withings: AsyncMock, + config_entry: MockConfigEntry, disable_webhook_delay, aioclient_mock: AiohttpClientMocker, ) -> None: """Test setup a non-unique profile.""" - config_entry = MockConfigEntry(domain=DOMAIN, data={PROFILE: "Henk"}, unique_id="0") - config_entry.add_to_hass(hass) + await setup_integration(hass, config_entry) result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER} ) @@ -126,47 +122,99 @@ async def test_config_non_unique_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": 10, + "userid": USER_ID, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "profile" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk"} + +async def test_config_reauth_profile( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test reauth an existing profile reauthenticates the config entry.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, ) + assert result["type"] == "form" + assert result["step_id"] == "reauth_confirm" - assert result - assert result["errors"]["base"] == "already_configured" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + 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" - result = await hass.config_entries.flow.async_configure( - result["flow_id"], user_input={PROFILE: "Henk 2"} + aioclient_mock.clear_requests() + aioclient_mock.post( + "https://wbsapi.withings.net/v2/oauth2", + json={ + "body": { + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + "userid": USER_ID, + }, + }, ) - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Henk 2" - assert "result" in result - assert result["result"].unique_id == "10" + + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "reauth_successful" -async def test_config_reauth_profile( +async def test_config_reauth_wrong_account( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, aioclient_mock: AiohttpClientMocker, config_entry: MockConfigEntry, + withings: AsyncMock, disable_webhook_delay, current_request_with_host, ) -> None: - """Test reauth an existing profile re-creates the config entry.""" - config_entry.add_to_hass(hass) + """Test reauth with wrong account.""" + await setup_integration(hass, config_entry) - config_entry.async_start_reauth(hass) - await hass.async_block_till_done() - - flows = hass.config_entries.flow.async_progress() - assert len(flows) == 1 - result = flows[0] + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "entry_id": config_entry.entry_id, + }, + data=config_entry.data, + ) + assert result["type"] == "form" assert result["step_id"] == "reauth_confirm" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -198,12 +246,12 @@ async def test_config_reauth_profile( "access_token": "mock-access-token", "type": "Bearer", "expires_in": 60, - "userid": "0", + "userid": 12346, }, }, ) result = await hass.config_entries.flow.async_configure(result["flow_id"]) assert result - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"]["token"]["refresh_token"] == "mock-refresh-token" + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "wrong_account" diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index acd21886e781c6..4e7eb812f0a6bd 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -1,31 +1,20 @@ """Tests for the Withings component.""" from datetime import timedelta -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import AsyncMock, MagicMock from urllib.parse import urlparse import pytest import voluptuous as vol -from withings_api.common import NotifyAppli, UnauthorizedException +from withings_api.common import NotifyAppli -import homeassistant.components.webhook as webhook from homeassistant.components.webhook import async_generate_url from homeassistant.components.withings import CONFIG_SCHEMA, DOMAIN, async_setup, const -from homeassistant.components.withings.common import ConfigEntryWithingsApi, DataManager -from homeassistant.config import async_process_ha_core_config -from homeassistant.const import ( - CONF_CLIENT_ID, - CONF_CLIENT_SECRET, - CONF_EXTERNAL_URL, - CONF_UNIT_SYSTEM, - CONF_UNIT_SYSTEM_METRIC, -) -from homeassistant.core import DOMAIN as HA_DOMAIN, HomeAssistant -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from homeassistant.setup import async_setup_component +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 setup_integration -from .common import ComponentFactory, get_data_manager_by_user_id, new_profile_config +from . import enable_webhooks, setup_integration from .conftest import WEBHOOK_ID from tests.common import MockConfigEntry, async_fire_time_changed @@ -113,126 +102,6 @@ async def test_async_setup_no_config(hass: HomeAssistant) -> None: hass.async_create_task.assert_not_called() -@pytest.mark.parametrize( - "exception", - [ - UnauthorizedException("401"), - UnauthorizedException("401"), - Exception("401, this is the message"), - ], -) -@patch("homeassistant.components.withings.common._RETRY_COEFFICIENT", 0) -async def test_auth_failure( - hass: HomeAssistant, - component_factory: ComponentFactory, - exception: Exception, - current_request_with_host: None, -) -> None: - """Test auth failure.""" - person0 = new_profile_config( - "person0", - 0, - api_response_user_get_device=exception, - api_response_measure_get_meas=exception, - api_response_sleep_get_summary=exception, - ) - - await component_factory.configure_component(profile_configs=(person0,)) - assert not hass.config_entries.flow.async_progress() - - await component_factory.setup_profile(person0.user_id) - data_manager = get_data_manager_by_user_id(hass, person0.user_id) - await data_manager.poll_data_update_coordinator.async_refresh() - - flows = hass.config_entries.flow.async_progress() - assert flows - assert len(flows) == 1 - - flow = flows[0] - assert flow["handler"] == const.DOMAIN - - result = await hass.config_entries.flow.async_configure( - flow["flow_id"], user_input={} - ) - assert result - assert result["type"] == "external" - assert result["handler"] == const.DOMAIN - assert result["step_id"] == "auth" - - await component_factory.unload(person0) - - -async def test_set_config_unique_id( - hass: HomeAssistant, component_factory: ComponentFactory -) -> None: - """Test upgrading configs to use a unique id.""" - person0 = new_profile_config("person0", 0) - - await component_factory.configure_component(profile_configs=(person0,)) - - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": "my_user_id"}, - "auth_implementation": "withings", - "profile": person0.profile, - }, - ) - - with patch("homeassistant.components.withings.async_get_data_manager") as mock: - data_manager: DataManager = MagicMock(spec=DataManager) - data_manager.poll_data_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.poll_data_update_coordinator.last_update_success = True - data_manager.subscription_update_coordinator = MagicMock( - spec=DataUpdateCoordinator - ) - data_manager.subscription_update_coordinator.last_update_success = True - mock.return_value = data_manager - config_entry.add_to_hass(hass) - - await hass.config_entries.async_setup(config_entry.entry_id) - assert config_entry.unique_id == "my_user_id" - - -async def test_set_convert_unique_id_to_string(hass: HomeAssistant) -> None: - """Test upgrading configs to use a unique id.""" - config_entry = MockConfigEntry( - domain=DOMAIN, - data={ - "token": {"userid": 1234}, - "auth_implementation": "withings", - "profile": "person0", - }, - ) - config_entry.add_to_hass(hass) - - hass_config = { - HA_DOMAIN: { - CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_METRIC, - CONF_EXTERNAL_URL: "http://127.0.0.1:8080/", - }, - const.DOMAIN: { - CONF_CLIENT_ID: "my_client_id", - CONF_CLIENT_SECRET: "my_client_secret", - const.CONF_USE_WEBHOOK: False, - }, - } - - with patch( - "homeassistant.components.withings.common.ConfigEntryWithingsApi", - spec=ConfigEntryWithingsApi, - ): - await async_process_ha_core_config(hass, hass_config.get(HA_DOMAIN)) - assert await async_setup_component(hass, HA_DOMAIN, {}) - assert await async_setup_component(hass, webhook.DOMAIN, hass_config) - assert await async_setup_component(hass, const.DOMAIN, hass_config) - await hass.async_block_till_done() - - assert config_entry.unique_id == "1234" - - async def test_data_manager_webhook_subscription( hass: HomeAssistant, withings: AsyncMock, @@ -241,6 +110,7 @@ async def test_data_manager_webhook_subscription( hass_client_no_auth: ClientSessionGenerator, ) -> None: """Test data manager webhook subscriptions.""" + await enable_webhooks(hass) await setup_integration(hass, config_entry) await hass_client_no_auth() await hass.async_block_till_done() @@ -285,3 +155,87 @@ async def test_requests( path=urlparse(webhook_url).path, ) assert response.status == 200 + + +@pytest.mark.parametrize( + ("config_entry"), + [ + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + "webhook_id": "3290798afaebd28519c4883d3d411c7197572e0cc9b8d507471f59a700a61a55", + }, + ), + MockConfigEntry( + domain=DOMAIN, + unique_id="123", + data={ + "token": {"userid": 123}, + "profile": "henk", + "use_webhook": False, + }, + ), + ], +) +async def test_config_flow_upgrade( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test config flow upgrade.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entry = hass.config_entries.async_get_entry(config_entry.entry_id) + + assert entry.unique_id == "123" + assert entry.data["token"]["userid"] == 123 + assert CONF_WEBHOOK_ID in entry.data + assert entry.options == { + "use_webhook": False, + } + + +@pytest.mark.parametrize( + ("body", "expected_code"), + [ + [{"userid": 0, "appli": NotifyAppli.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}, + 0, + ], # Success, we ignore the user_id + ], +) +async def test_webhook_post( + hass: HomeAssistant, + withings: AsyncMock, + config_entry: MockConfigEntry, + hass_client_no_auth: ClientSessionGenerator, + disable_webhook_delay, + body: dict[str, Any], + expected_code: int, + current_request_with_host: None, +) -> None: + """Test webhook callback.""" + await setup_integration(hass, config_entry) + client = await hass_client_no_auth() + webhook_url = async_generate_url(hass, WEBHOOK_ID) + + resp = await client.post(urlparse(webhook_url).path, data=body) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + + data = await resp.json() + resp.close() + + assert data["code"] == expected_code From c3a7aee48e1bb7706106659b72d6c23067888ab0 Mon Sep 17 00:00:00 2001 From: Charles Garwood Date: Wed, 13 Sep 2023 10:04:34 -0400 Subject: [PATCH 469/640] Bump pyenphase to 1.11.4 (#100288) --- 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 aa801fea14ed72..917e325be5134c 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.3"], + "requirements": ["pyenphase==1.11.4"], "zeroconf": [ { "type": "_enphase-envoy._tcp.local." diff --git a/requirements_all.txt b/requirements_all.txt index c27c11b6a3e817..a9c7116ad3f420 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1675,7 +1675,7 @@ pyedimax==0.2.1 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.3 +pyenphase==1.11.4 # homeassistant.components.envisalink pyenvisalink==4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aceab8ce5a5c53..e0901e29357b69 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1245,7 +1245,7 @@ pyeconet==0.1.20 pyefergy==22.1.1 # homeassistant.components.enphase_envoy -pyenphase==1.11.3 +pyenphase==1.11.4 # homeassistant.components.everlights pyeverlights==0.1.0 From f2f45380a98b460ada24dc6e0957ec14db7e1b66 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 16:34:14 +0200 Subject: [PATCH 470/640] Use shorthand attrs in iaqualink (#100281) * Use shorthand attrs in iaqualink * Use super * Update homeassistant/components/iaqualink/light.py Co-authored-by: Joost Lekkerkerker * Remove self * More follow ups * Remove cast and type check * Update homeassistant/components/iaqualink/__init__.py Co-authored-by: Joost Lekkerkerker --------- Co-authored-by: Joost Lekkerkerker --- .../components/iaqualink/__init__.py | 28 ++++-------- .../components/iaqualink/binary_sensor.py | 19 ++++---- homeassistant/components/iaqualink/climate.py | 34 ++++++--------- homeassistant/components/iaqualink/light.py | 43 ++++++------------- homeassistant/components/iaqualink/sensor.py | 33 ++++++-------- homeassistant/components/iaqualink/switch.py | 31 ++++++------- 6 files changed, 71 insertions(+), 117 deletions(-) diff --git a/homeassistant/components/iaqualink/__init__.py b/homeassistant/components/iaqualink/__init__.py index 9554d30df45ffb..fceb0d72213751 100644 --- a/homeassistant/components/iaqualink/__init__.py +++ b/homeassistant/components/iaqualink/__init__.py @@ -6,7 +6,7 @@ from datetime import datetime from functools import wraps import logging -from typing import Any, Concatenate, ParamSpec, TypeVar, cast +from typing import Any, Concatenate, ParamSpec, TypeVar import httpx from iaqualink.client import AqualinkClient @@ -215,6 +215,14 @@ class AqualinkEntity(Entity): def __init__(self, dev: AqualinkDevice) -> None: """Initialize the entity.""" self.dev = dev + self._attr_unique_id = f"{dev.system.serial}_{dev.name}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._attr_unique_id)}, + manufacturer=dev.manufacturer, + model=dev.model, + name=dev.label, + via_device=(DOMAIN, dev.system.serial), + ) async def async_added_to_hass(self) -> None: """Set up a listener when this entity is added to HA.""" @@ -222,11 +230,6 @@ async def async_added_to_hass(self) -> None: async_dispatcher_connect(self.hass, DOMAIN, self.async_write_ha_state) ) - @property - def unique_id(self) -> str: - """Return a unique identifier for this entity.""" - return f"{self.dev.system.serial}_{self.dev.name}" - @property def assumed_state(self) -> bool: """Return whether the state is based on actual reading from the device.""" @@ -236,16 +239,3 @@ def assumed_state(self) -> bool: def available(self) -> bool: """Return whether the device is available or not.""" return self.dev.system.online is True - - @property - def device_info(self) -> DeviceInfo: - """Return the device info.""" - return DeviceInfo( - identifiers={(DOMAIN, self.unique_id)}, - manufacturer=self.dev.manufacturer, - model=self.dev.model, - # Instead of setting the device name to the entity name, iaqualink - # should be updated to set has_entity_name = True - name=cast(str | None, self.name), - via_device=(DOMAIN, self.dev.system.serial), - ) diff --git a/homeassistant/components/iaqualink/binary_sensor.py b/homeassistant/components/iaqualink/binary_sensor.py index 7513a15272c4d2..149261f97fc05a 100644 --- a/homeassistant/components/iaqualink/binary_sensor.py +++ b/homeassistant/components/iaqualink/binary_sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkBinarySensor + from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDeviceClass, @@ -31,19 +33,14 @@ async def async_setup_entry( class HassAqualinkBinarySensor(AqualinkEntity, BinarySensorEntity): """Representation of a binary sensor.""" - @property - def name(self) -> str: - """Return the name of the binary sensor.""" - return self.dev.label + def __init__(self, dev: AqualinkBinarySensor) -> None: + """Initialize AquaLink binary sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.label == "Freeze Protection": + self._attr_device_class = BinarySensorDeviceClass.COLD @property def is_on(self) -> bool: """Return whether the binary sensor is on or not.""" return self.dev.is_on - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of the binary sensor.""" - if self.name == "Freeze Protection": - return BinarySensorDeviceClass.COLD - return None diff --git a/homeassistant/components/iaqualink/climate.py b/homeassistant/components/iaqualink/climate.py index 7c67dbdea4b3fb..b7dbe43fca99c3 100644 --- a/homeassistant/components/iaqualink/climate.py +++ b/homeassistant/components/iaqualink/climate.py @@ -4,6 +4,8 @@ import logging from typing import Any +from iaqualink.device import AqualinkThermostat + from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateEntity, @@ -42,10 +44,17 @@ class HassAqualinkThermostat(AqualinkEntity, ClimateEntity): _attr_hvac_modes = [HVACMode.HEAT, HVACMode.OFF] _attr_supported_features = ClimateEntityFeature.TARGET_TEMPERATURE - @property - def name(self) -> str: - """Return the name of the thermostat.""" - return self.dev.label.split(" ")[0] + def __init__(self, dev: AqualinkThermostat) -> None: + """Initialize AquaLink thermostat.""" + super().__init__(dev) + self._attr_name = dev.label.split(" ")[0] + self._attr_temperature_unit = ( + UnitOfTemperature.FAHRENHEIT + if dev.unit == "F" + else UnitOfTemperature.CELSIUS + ) + self._attr_min_temp = dev.min_temperature + self._attr_max_temp = dev.max_temperature @property def hvac_mode(self) -> HVACMode: @@ -64,23 +73,6 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: else: _LOGGER.warning("Unknown operation mode: %s", hvac_mode) - @property - def temperature_unit(self) -> str: - """Return the unit of measurement.""" - if self.dev.unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - - @property - def min_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.min_temperature - - @property - def max_temp(self) -> int: - """Return the minimum temperature supported by the thermostat.""" - return self.dev.max_temperature - @property def target_temperature(self) -> float: """Return the current target temperature.""" diff --git a/homeassistant/components/iaqualink/light.py b/homeassistant/components/iaqualink/light.py index 8b83f7019152b6..3a166ba593ddaa 100644 --- a/homeassistant/components/iaqualink/light.py +++ b/homeassistant/components/iaqualink/light.py @@ -3,6 +3,8 @@ from typing import Any +from iaqualink.device import AqualinkLight + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, @@ -37,10 +39,18 @@ async def async_setup_entry( class HassAqualinkLight(AqualinkEntity, LightEntity): """Representation of a light.""" - @property - def name(self) -> str: - """Return the name of the light.""" - return self.dev.label + def __init__(self, dev: AqualinkLight) -> None: + """Initialize AquaLink light.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.supports_effect: + self._attr_effect_list = list(dev.supported_effects) + self._attr_supported_features = LightEntityFeature.EFFECT + color_mode = ColorMode.ONOFF + if dev.supports_brightness: + color_mode = ColorMode.BRIGHTNESS + self._attr_color_mode = color_mode + self._attr_supported_color_modes = {color_mode} @property def is_on(self) -> bool: @@ -81,28 +91,3 @@ def brightness(self) -> int: def effect(self) -> str: """Return the current light effect if supported.""" return self.dev.effect - - @property - def effect_list(self) -> list[str]: - """Return supported light effects.""" - return list(self.dev.supported_effects) - - @property - def color_mode(self) -> ColorMode: - """Return the color mode of the light.""" - if self.dev.supports_brightness: - return ColorMode.BRIGHTNESS - return ColorMode.ONOFF - - @property - def supported_color_modes(self) -> set[ColorMode]: - """Flag supported color modes.""" - return {self.color_mode} - - @property - def supported_features(self) -> LightEntityFeature: - """Return the list of features supported by the light.""" - if self.dev.supports_effect: - return LightEntityFeature.EFFECT - - return LightEntityFeature(0) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index 8086aa29ee0de0..b18a85a43a50d8 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -1,6 +1,8 @@ """Support for Aqualink temperature sensors.""" from __future__ import annotations +from iaqualink.device import AqualinkSensor + from homeassistant.components.sensor import DOMAIN, SensorDeviceClass, SensorEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTemperature @@ -28,19 +30,17 @@ async def async_setup_entry( class HassAqualinkSensor(AqualinkEntity, SensorEntity): """Representation of a sensor.""" - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self.dev.label - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the measurement unit for the sensor.""" - if self.dev.name.endswith("_temp"): - if self.dev.system.temp_unit == "F": - return UnitOfTemperature.FAHRENHEIT - return UnitOfTemperature.CELSIUS - return None + def __init__(self, dev: AqualinkSensor) -> None: + """Initialize AquaLink sensor.""" + super().__init__(dev) + self._attr_name = dev.label + if dev.name.endswith("_temp"): + self._attr_native_unit_of_measurement = ( + UnitOfTemperature.FAHRENHEIT + if dev.system.temp_unit == "F" + else UnitOfTemperature.CELSIUS + ) + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self) -> int | float | None: @@ -52,10 +52,3 @@ def native_value(self) -> int | float | None: return int(self.dev.state) except ValueError: return float(self.dev.state) - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of the sensor.""" - if self.dev.name.endswith("_temp"): - return SensorDeviceClass.TEMPERATURE - return None diff --git a/homeassistant/components/iaqualink/switch.py b/homeassistant/components/iaqualink/switch.py index 8f482e8730f351..590fcd6141966e 100644 --- a/homeassistant/components/iaqualink/switch.py +++ b/homeassistant/components/iaqualink/switch.py @@ -3,6 +3,8 @@ from typing import Any +from iaqualink.device import AqualinkSwitch + from homeassistant.components.switch import DOMAIN, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -30,23 +32,18 @@ async def async_setup_entry( class HassAqualinkSwitch(AqualinkEntity, SwitchEntity): """Representation of a switch.""" - @property - def name(self) -> str: - """Return the name of the switch.""" - return self.dev.label - - @property - def icon(self) -> str | None: - """Return an icon based on the switch type.""" - if self.name == "Cleaner": - return "mdi:robot-vacuum" - if self.name == "Waterfall" or self.name.endswith("Dscnt"): - return "mdi:fountain" - if self.name.endswith("Pump") or self.name.endswith("Blower"): - return "mdi:fan" - if self.name.endswith("Heater"): - return "mdi:radiator" - return None + def __init__(self, dev: AqualinkSwitch) -> None: + """Initialize AquaLink switch.""" + super().__init__(dev) + name = self._attr_name = dev.label + if name == "Cleaner": + self._attr_icon = "mdi:robot-vacuum" + elif name == "Waterfall" or name.endswith("Dscnt"): + self._attr_icon = "mdi:fountain" + elif name.endswith("Pump") or name.endswith("Blower"): + self._attr_icon = "mdi:fan" + if name.endswith("Heater"): + self._attr_icon = "mdi:radiator" @property def is_on(self) -> bool: From 871800778f39812b3fe9af7b8ac2d82eb63b0a74 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 16:50:33 +0200 Subject: [PATCH 471/640] Use shorthand attrs for velux (#100294) * Use shorthand attrs for velux * Update homeassistant/components/velux/cover.py Co-authored-by: Joost Lekkerkerker * black --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/velux/__init__.py | 18 +++------- homeassistant/components/velux/cover.py | 39 ++++++++++++---------- 2 files changed, 25 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 900453581365f8..ef5525731153e6 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -1,7 +1,7 @@ """Support for VELUX KLF 200 devices.""" import logging -from pyvlx import PyVLX, PyVLXException +from pyvlx import OpeningDevice, PyVLX, PyVLXException import voluptuous as vol from homeassistant.const import ( @@ -90,9 +90,11 @@ class VeluxEntity(Entity): _attr_should_poll = False - def __init__(self, node): + def __init__(self, node: OpeningDevice) -> None: """Initialize the Velux device.""" self.node = node + self._attr_unique_id = node.serial_number + self._attr_name = node.name if node.name else "#" + str(node.node_id) @callback def async_register_callbacks(self): @@ -107,15 +109,3 @@ async def after_update_callback(device): async def async_added_to_hass(self): """Store register state change callback.""" self.async_register_callbacks() - - @property - def unique_id(self) -> str: - """Return the unique id base on the serial_id returned by Velux.""" - return self.node.serial_number - - @property - def name(self): - """Return the name of the Velux device.""" - if not self.node.name: - return "#" + str(self.node.node_id) - return self.node.name diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index c924fe5c10b862..48c09a2b3c21c5 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -39,6 +39,26 @@ async def async_setup_platform( class VeluxCover(VeluxEntity, CoverEntity): """Representation of a Velux cover.""" + _is_blind = False + + def __init__(self, node: OpeningDevice) -> None: + """Initialize VeluxCover.""" + super().__init__(node) + self._attr_device_class = CoverDeviceClass.WINDOW + if isinstance(node, Awning): + self._attr_device_class = CoverDeviceClass.AWNING + if isinstance(node, Blind): + self._attr_device_class = CoverDeviceClass.BLIND + self._is_blind = True + if isinstance(node, GarageDoor): + self._attr_device_class = CoverDeviceClass.GARAGE + if isinstance(node, Gate): + self._attr_device_class = CoverDeviceClass.GATE + if isinstance(node, RollerShutter): + self._attr_device_class = CoverDeviceClass.SHUTTER + if isinstance(node, Window): + self._attr_device_class = CoverDeviceClass.WINDOW + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -65,27 +85,10 @@ def current_cover_position(self) -> int: @property def current_cover_tilt_position(self) -> int | None: """Return the current position of the cover.""" - if isinstance(self.node, Blind): + if self._is_blind: return 100 - self.node.orientation.position_percent return None - @property - def device_class(self) -> CoverDeviceClass: - """Define this cover as either awning, blind, garage, gate, shutter or window.""" - if isinstance(self.node, Awning): - return CoverDeviceClass.AWNING - if isinstance(self.node, Blind): - return CoverDeviceClass.BLIND - if isinstance(self.node, GarageDoor): - return CoverDeviceClass.GARAGE - if isinstance(self.node, Gate): - return CoverDeviceClass.GATE - if isinstance(self.node, RollerShutter): - return CoverDeviceClass.SHUTTER - if isinstance(self.node, Window): - return CoverDeviceClass.WINDOW - return CoverDeviceClass.WINDOW - @property def is_closed(self) -> bool: """Return if the cover is closed.""" From d0feb063ec3c5ec94502a7406c2811c4e47f61d9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 10:03:36 -0500 Subject: [PATCH 472/640] Fix missing super async_added_to_hass in lookin (#100296) --- homeassistant/components/lookin/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/lookin/entity.py b/homeassistant/components/lookin/entity.py index d20a21bd23c9fe..0e518ffc1e5eee 100644 --- a/homeassistant/components/lookin/entity.py +++ b/homeassistant/components/lookin/entity.py @@ -182,6 +182,7 @@ async def _async_push_update_device(self, event: UDPEvent) -> None: async def async_added_to_hass(self) -> None: """Call when the entity is added to hass.""" + await super().async_added_to_hass() self.async_on_remove( self._lookin_udp_subs.subscribe_event( self._lookin_device.id, From 6057fe5926f4ab2a2c8b7e0c2d94e455d668a30d Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 18:05:17 +0200 Subject: [PATCH 473/640] Replace StateMachine._domain_index with a UserDict (#100270) * Replace StateMachine._domain_index with a UserDict * Access the UserDict's backing dict directly * Optimize --- homeassistant/core.py | 102 +++++++++++++++++++++++++++++++----------- tests/test_core.py | 27 +++++++---- 2 files changed, 94 insertions(+), 35 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 17b8b5f2e85da2..cbfc8097c7fa26 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -6,7 +6,16 @@ from __future__ import annotations import asyncio -from collections.abc import Callable, Collection, Coroutine, Iterable, Mapping +from collections import UserDict, defaultdict +from collections.abc import ( + Callable, + Collection, + Coroutine, + Iterable, + KeysView, + Mapping, + ValuesView, +) import concurrent.futures from contextlib import suppress import datetime @@ -1413,15 +1422,59 @@ def __repr__(self) -> str: ) +class States(UserDict[str, State]): + """Container for states, maps entity_id -> State. + + Maintains an additional index: + - domain -> dict[str, State] + """ + + def __init__(self) -> None: + """Initialize the container.""" + super().__init__() + self._domain_index: defaultdict[str, dict[str, State]] = defaultdict(dict) + + def values(self) -> ValuesView[State]: + """Return the underlying values to avoid __iter__ overhead.""" + return self.data.values() + + def __setitem__(self, key: str, entry: State) -> None: + """Add an item.""" + self.data[key] = entry + self._domain_index[entry.domain][entry.entity_id] = entry + + def __delitem__(self, key: str) -> None: + """Remove an item.""" + entry = self[key] + del self._domain_index[entry.domain][entry.entity_id] + super().__delitem__(key) + + def domain_entity_ids(self, key: str) -> KeysView[str] | tuple[()]: + """Get all entity_ids for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].keys() + + def domain_states(self, key: str) -> ValuesView[State] | tuple[()]: + """Get all states for a domain.""" + # Avoid polluting _domain_index with non-existing domains + if key not in self._domain_index: + return () + return self._domain_index[key].values() + + class StateMachine: """Helper class that tracks the state of different entities.""" - __slots__ = ("_states", "_domain_index", "_reservations", "_bus", "_loop") + __slots__ = ("_states", "_states_data", "_reservations", "_bus", "_loop") def __init__(self, bus: EventBus, loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" - self._states: dict[str, State] = {} - self._domain_index: dict[str, dict[str, State]] = {} + self._states = States() + # _states_data is used to access the States backing dict directly to speed + # up read operations + self._states_data = self._states.data self._reservations: set[str] = set() self._bus = bus self._loop = loop @@ -1442,16 +1495,15 @@ def async_entity_ids( This method must be run in the event loop. """ if domain_filter is None: - return list(self._states) + return list(self._states_data) if isinstance(domain_filter, str): - return list(self._domain_index.get(domain_filter.lower(), ())) + return list(self._states.domain_entity_ids(domain_filter.lower())) - states: list[str] = [] + entity_ids: list[str] = [] for domain in domain_filter: - if domain_index := self._domain_index.get(domain): - states.extend(domain_index) - return states + entity_ids.extend(self._states.domain_entity_ids(domain)) + return entity_ids @callback def async_entity_ids_count( @@ -1462,12 +1514,14 @@ def async_entity_ids_count( This method must be run in the event loop. """ if domain_filter is None: - return len(self._states) + return len(self._states_data) if isinstance(domain_filter, str): - return len(self._domain_index.get(domain_filter.lower(), ())) + return len(self._states.domain_entity_ids(domain_filter.lower())) - return sum(len(self._domain_index.get(domain, ())) for domain in domain_filter) + return sum( + len(self._states.domain_entity_ids(domain)) for domain in domain_filter + ) def all(self, domain_filter: str | Iterable[str] | None = None) -> list[State]: """Create a list of all states.""" @@ -1484,15 +1538,14 @@ def async_all( This method must be run in the event loop. """ if domain_filter is None: - return list(self._states.values()) + return list(self._states_data.values()) if isinstance(domain_filter, str): - return list(self._domain_index.get(domain_filter.lower(), {}).values()) + return list(self._states.domain_states(domain_filter.lower())) states: list[State] = [] for domain in domain_filter: - if domain_index := self._domain_index.get(domain): - states.extend(domain_index.values()) + states.extend(self._states.domain_states(domain)) return states def get(self, entity_id: str) -> State | None: @@ -1500,7 +1553,7 @@ def get(self, entity_id: str) -> State | None: Async friendly. """ - return self._states.get(entity_id.lower()) + return self._states_data.get(entity_id.lower()) def is_state(self, entity_id: str, state: str) -> bool: """Test if entity exists and is in specified state. @@ -1534,7 +1587,6 @@ def async_remove(self, entity_id: str, context: Context | None = None) -> bool: if old_state is None: return False - self._domain_index[old_state.domain].pop(entity_id) old_state.expire() self._bus.async_fire( EVENT_STATE_CHANGED, @@ -1579,7 +1631,7 @@ def async_reserve(self, entity_id: str) -> None: entity_id are added. """ entity_id = entity_id.lower() - if entity_id in self._states or entity_id in self._reservations: + if entity_id in self._states_data or entity_id in self._reservations: raise HomeAssistantError( "async_reserve must not be called once the state is in the state" " machine." @@ -1591,7 +1643,9 @@ def async_reserve(self, entity_id: str) -> None: def async_available(self, entity_id: str) -> bool: """Check to see if an entity_id is available to be used.""" entity_id = entity_id.lower() - return entity_id not in self._states and entity_id not in self._reservations + return ( + entity_id not in self._states_data and entity_id not in self._reservations + ) @callback def async_set( @@ -1614,7 +1668,7 @@ def async_set( entity_id = entity_id.lower() new_state = str(new_state) attributes = attributes or {} - if (old_state := self._states.get(entity_id)) is None: + if (old_state := self._states_data.get(entity_id)) is None: same_state = False same_attr = False last_changed = None @@ -1656,10 +1710,6 @@ def async_set( if old_state is not None: old_state.expire() self._states[entity_id] = state - if not (domain_index := self._domain_index.get(state.domain)): - domain_index = {} - self._domain_index[state.domain] = domain_index - domain_index[entity_id] = state self._bus.async_fire( EVENT_STATE_CHANGED, {"entity_id": entity_id, "old_state": old_state, "new_state": state}, diff --git a/tests/test_core.py b/tests/test_core.py index 5dcbb81db68af6..c5ce9eb0881513 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock, Mock, PropertyMock, patch import pytest +from pytest_unordered import unordered import voluptuous as vol from homeassistant.const import ( @@ -1031,17 +1032,18 @@ async def test_statemachine_is_state(hass: HomeAssistant) -> None: async def test_statemachine_entity_ids(hass: HomeAssistant) -> None: - """Test get_entity_ids method.""" + """Test async_entity_ids method.""" + assert hass.states.async_entity_ids() == [] + assert hass.states.async_entity_ids("light") == [] + assert hass.states.async_entity_ids(("light", "switch", "other")) == [] + hass.states.async_set("light.bowl", "on", {}) hass.states.async_set("SWITCH.AC", "off", {}) - ent_ids = hass.states.async_entity_ids() - assert len(ent_ids) == 2 - assert "light.bowl" in ent_ids - assert "switch.ac" in ent_ids - - ent_ids = hass.states.async_entity_ids("light") - assert len(ent_ids) == 1 - assert "light.bowl" in ent_ids + assert hass.states.async_entity_ids() == unordered(["light.bowl", "switch.ac"]) + assert hass.states.async_entity_ids("light") == ["light.bowl"] + assert hass.states.async_entity_ids(("light", "switch", "other")) == unordered( + ["light.bowl", "switch.ac"] + ) states = sorted(state.entity_id for state in hass.states.async_all()) assert states == ["light.bowl", "switch.ac"] @@ -1902,6 +1904,9 @@ async def _task_chain_2(): async def test_async_all(hass: HomeAssistant) -> None: """Test async_all.""" + assert hass.states.async_all() == [] + assert hass.states.async_all("light") == [] + assert hass.states.async_all(["light", "switch"]) == [] hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") @@ -1926,6 +1931,10 @@ async def test_async_all(hass: HomeAssistant) -> None: async def test_async_entity_ids_count(hass: HomeAssistant) -> None: """Test async_entity_ids_count.""" + assert hass.states.async_entity_ids_count() == 0 + assert hass.states.async_entity_ids_count("light") == 0 + assert hass.states.async_entity_ids_count({"light", "vacuum"}) == 0 + hass.states.async_set("switch.link", "on") hass.states.async_set("light.bowl", "on") hass.states.async_set("light.frog", "on") From f6b094dfee5be794ebe3b224a532d0182b3e1b09 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 13 Sep 2023 18:08:15 +0200 Subject: [PATCH 474/640] Add options flow to Withings (#100300) --- .../components/withings/config_flow.py | 32 ++++++++++++++++++- .../components/withings/strings.json | 9 ++++++ tests/components/withings/test_config_flow.py | 30 ++++++++++++++++- 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index 7bbf869069f4ce..f25ef95210c369 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -5,10 +5,12 @@ import logging from typing import Any +import voluptuous as vol from withings_api.common import AuthScope -from homeassistant.config_entries import ConfigEntry +from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry from homeassistant.const import CONF_TOKEN +from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -24,6 +26,14 @@ class WithingsFlowHandler( reauth_entry: ConfigEntry | None = None + @staticmethod + @callback + def async_get_options_flow( + config_entry: ConfigEntry, + ) -> WithingsOptionsFlowHandler: + """Get the options flow for this handler.""" + return WithingsOptionsFlowHandler(config_entry) + @property def logger(self) -> logging.Logger: """Return logger.""" @@ -77,3 +87,23 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: return self.async_abort(reason="reauth_successful") return self.async_abort(reason="wrong_account") + + +class WithingsOptionsFlowHandler(OptionsFlowWithConfigEntry): + """Withings Options flow handler.""" + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize form.""" + if user_input is not None: + return self.async_create_entry( + data=user_input, + ) + return self.async_show_form( + step_id="init", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_USE_WEBHOOK): bool}), + self.options, + ), + ) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 424a0edadce184..5fa155a1c1c7e6 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -28,6 +28,15 @@ "default": "Successfully authenticated with Withings." } }, + "options": { + "step": { + "init": { + "data": { + "use_webhook": "Use webhooks" + } + } + } + }, "entity": { "binary_sensor": { "in_bed": { diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 768f6fed16d7d4..52a584e2513cf5 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -1,7 +1,7 @@ """Tests for config flow.""" from unittest.mock import AsyncMock, patch -from homeassistant.components.withings.const import DOMAIN +from homeassistant.components.withings.const import CONF_USE_WEBHOOK, DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -255,3 +255,31 @@ async def test_config_reauth_wrong_account( assert result assert result["type"] == FlowResultType.ABORT assert result["reason"] == "wrong_account" + + +async def test_options_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + config_entry: MockConfigEntry, + withings: AsyncMock, + disable_webhook_delay, + current_request_with_host, +) -> None: + """Test options flow.""" + await setup_integration(hass, config_entry) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={CONF_USE_WEBHOOK: True}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_USE_WEBHOOK: True} From ee65aa91e86c2976ab1301e67cea76c9126dd065 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Wed, 13 Sep 2023 18:09:12 +0200 Subject: [PATCH 475/640] Allow setting the elevation in `set_location` (#99978) Co-authored-by: G Johansson --- .../components/homeassistant/__init__.py | 21 +++++++++++++++---- .../components/homeassistant/services.yaml | 5 +++++ .../components/homeassistant/strings.json | 4 ++++ homeassistant/const.py | 3 +++ tests/components/homeassistant/test_init.py | 11 ++++++++++ 5 files changed, 40 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homeassistant/__init__.py b/homeassistant/components/homeassistant/__init__.py index 987a4317ba84ac..e4032ad954d297 100644 --- a/homeassistant/components/homeassistant/__init__.py +++ b/homeassistant/components/homeassistant/__init__.py @@ -9,6 +9,7 @@ from homeassistant.components import persistent_notification import homeassistant.config as conf_util from homeassistant.const import ( + ATTR_ELEVATION, ATTR_ENTITY_ID, ATTR_LATITUDE, ATTR_LONGITUDE, @@ -250,16 +251,28 @@ async def async_handle_reload_config(call: ha.ServiceCall) -> None: async def async_set_location(call: ha.ServiceCall) -> None: """Service handler to set location.""" - await hass.config.async_update( - latitude=call.data[ATTR_LATITUDE], longitude=call.data[ATTR_LONGITUDE] - ) + service_data = { + "latitude": call.data[ATTR_LATITUDE], + "longitude": call.data[ATTR_LONGITUDE], + } + + if elevation := call.data.get(ATTR_ELEVATION): + service_data["elevation"] = elevation + + await hass.config.async_update(**service_data) async_register_admin_service( hass, ha.DOMAIN, SERVICE_SET_LOCATION, async_set_location, - vol.Schema({ATTR_LATITUDE: cv.latitude, ATTR_LONGITUDE: cv.longitude}), + vol.Schema( + { + vol.Required(ATTR_LATITUDE): cv.latitude, + vol.Required(ATTR_LONGITUDE): cv.longitude, + vol.Optional(ATTR_ELEVATION): int, + } + ), ) async def async_handle_reload_templates(call: ha.ServiceCall) -> None: diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 899fee357fd527..09a280133f2185 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -13,6 +13,11 @@ set_location: example: 117.22743 selector: text: + elevation: + required: false + example: 120 + selector: + text: stop: toggle: diff --git a/homeassistant/components/homeassistant/strings.json b/homeassistant/components/homeassistant/strings.json index 5404ee4af6496e..53510a94f01953 100644 --- a/homeassistant/components/homeassistant/strings.json +++ b/homeassistant/components/homeassistant/strings.json @@ -81,6 +81,10 @@ "longitude": { "name": "[%key:common::config_flow::data::longitude%]", "description": "Longitude of your location." + }, + "elevation": { + "name": "[%key:common::config_flow::data::elevation%]", + "description": "Elevation of your location." } } }, diff --git a/homeassistant/const.py b/homeassistant/const.py index 70f7827143b12f..de968451af9f45 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -460,6 +460,9 @@ class Platform(StrEnum): ATTR_LATITUDE: Final = "latitude" ATTR_LONGITUDE: Final = "longitude" +# Elevation of the entity +ATTR_ELEVATION: Final = "elevation" + # Accuracy of location in meters ATTR_GPS_ACCURACY: Final = "gps_accuracy" diff --git a/tests/components/homeassistant/test_init.py b/tests/components/homeassistant/test_init.py index 652fc4a1fdda78..4c5643ae3ca5bf 100644 --- a/tests/components/homeassistant/test_init.py +++ b/tests/components/homeassistant/test_init.py @@ -305,6 +305,8 @@ async def test_setting_location(hass: HomeAssistant) -> None: # Just to make sure that we are updating values. assert hass.config.latitude != 30 assert hass.config.longitude != 40 + elevation = hass.config.elevation + assert elevation != 50 await hass.services.async_call( "homeassistant", "set_location", @@ -314,6 +316,15 @@ async def test_setting_location(hass: HomeAssistant) -> None: assert len(events) == 1 assert hass.config.latitude == 30 assert hass.config.longitude == 40 + assert hass.config.elevation == elevation + + await hass.services.async_call( + "homeassistant", + "set_location", + {"latitude": 30, "longitude": 40, "elevation": 50}, + blocking=True, + ) + assert hass.config.elevation == 50 async def test_require_admin( From c3d1cdd0e933134f47bfa82d6b4ccefab6b40429 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 13 Sep 2023 18:09:34 +0200 Subject: [PATCH 476/640] Improve UserDict in device and entity registries (#100307) --- homeassistant/helpers/device_registry.py | 8 ++++---- homeassistant/helpers/entity_registry.py | 7 ++++--- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9c2492d65e8fb1..64d102d020fed4 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -392,14 +392,14 @@ def values(self) -> ValuesView[_EntryTypeT]: def __setitem__(self, key: str, entry: _EntryTypeT) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] for connection in old_entry.connections: del self._connections[connection] for identifier in old_entry.identifiers: del self._identifiers[identifier] - # type ignore linked to mypy issue: https://github.com/python/mypy/issues/13596 - super().__setitem__(key, entry) # type: ignore[assignment] + data[key] = entry for connection in entry.connections: self._connections[connection] = entry for identifier in entry.identifiers: diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 939c8986e71811..09f92a88882346 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -450,11 +450,12 @@ def values(self) -> ValuesView[RegistryEntry]: def __setitem__(self, key: str, entry: RegistryEntry) -> None: """Add an item.""" - if key in self: - old_entry = self[key] + data = self.data + if key in data: + old_entry = data[key] del self._entry_ids[old_entry.id] del self._index[(old_entry.domain, old_entry.platform, old_entry.unique_id)] - super().__setitem__(key, entry) + data[key] = entry self._entry_ids[entry.id] = entry self._index[(entry.domain, entry.platform, entry.unique_id)] = entry.entity_id From 6a2dd4fe742b998402e0539b2f13b80461bb82e5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 11:25:10 -0500 Subject: [PATCH 477/640] Bump yalexs to 1.9.0 (#100305) --- homeassistant/components/august/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/august/manifest.json b/homeassistant/components/august/manifest.json index a2d460d12ec2f8..c5a0da71136230 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.8.0", "yalexs-ble==2.3.0"] + "requirements": ["yalexs==1.9.0", "yalexs-ble==2.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index a9c7116ad3f420..c16584b68f64d9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2742,7 +2742,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e0901e29357b69..d25cb5dbb33faa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2027,7 +2027,7 @@ yalesmartalarmclient==0.3.9 yalexs-ble==2.3.0 # homeassistant.components.august -yalexs==1.8.0 +yalexs==1.9.0 # homeassistant.components.yeelight yeelight==0.7.13 From d17957ac1a8c15ae0389247880db330c211d3834 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 18:59:35 +0200 Subject: [PATCH 478/640] Update debugpy to 1.8.0 (#100311) --- homeassistant/components/debugpy/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/debugpy/manifest.json b/homeassistant/components/debugpy/manifest.json index 4fe141c4943a7a..d3ed35643441d6 100644 --- a/homeassistant/components/debugpy/manifest.json +++ b/homeassistant/components/debugpy/manifest.json @@ -6,5 +6,5 @@ "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["debugpy==1.6.7"] + "requirements": ["debugpy==1.8.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index c16584b68f64d9..e077804a71826b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -649,7 +649,7 @@ datapoint==0.9.8 dbus-fast==2.6.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.decora_wifi # decora-wifi==1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d25cb5dbb33faa..71bcf0a2a1952e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -532,7 +532,7 @@ datapoint==0.9.8 dbus-fast==2.6.0 # homeassistant.components.debugpy -debugpy==1.6.7 +debugpy==1.8.0 # homeassistant.components.ihc # homeassistant.components.namecheapdns From 0d33cba8235749cbd4030c59f510f521c68abcd2 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 13 Sep 2023 19:30:43 +0200 Subject: [PATCH 479/640] Use shorthand attrs in template integration (#100301) --- .../template/alarm_control_panel.py | 26 ++++----------- .../components/template/binary_sensor.py | 10 +----- homeassistant/components/template/cover.py | 33 ++++++------------- homeassistant/components/template/fan.py | 7 ++-- homeassistant/components/template/light.py | 19 +++++------ homeassistant/components/template/lock.py | 6 +--- homeassistant/components/template/sensor.py | 22 +++++++------ homeassistant/components/template/switch.py | 6 +--- 8 files changed, 42 insertions(+), 87 deletions(-) diff --git a/homeassistant/components/template/alarm_control_panel.py b/homeassistant/components/template/alarm_control_panel.py index af2e432c61e4cb..2cac5d74a7a30c 100644 --- a/homeassistant/components/template/alarm_control_panel.py +++ b/homeassistant/components/template/alarm_control_panel.py @@ -154,8 +154,8 @@ def __init__( name = self._attr_name self._template = config.get(CONF_VALUE_TEMPLATE) self._disarm_script = None - self._code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] - self._code_format: TemplateCodeFormat = config[CONF_CODE_FORMAT] + self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED] + self._attr_code_format = config[CONF_CODE_FORMAT].value if (disarm_action := config.get(CONF_DISARM_ACTION)) is not None: self._disarm_script = Script(hass, disarm_action, name, DOMAIN) self._arm_away_script = None @@ -183,14 +183,6 @@ def __init__( self._state: str | None = None - @property - def state(self) -> str | None: - """Return the state of the device.""" - return self._state - - @property - def supported_features(self) -> AlarmControlPanelEntityFeature: - """Return the list of supported features.""" supported_features = AlarmControlPanelEntityFeature(0) if self._arm_night_script is not None: supported_features = ( @@ -221,18 +213,12 @@ def supported_features(self) -> AlarmControlPanelEntityFeature: supported_features = ( supported_features | AlarmControlPanelEntityFeature.TRIGGER ) - - return supported_features - - @property - def code_format(self) -> CodeFormat | None: - """Regex for code format or None if no code is required.""" - return self._code_format.value + self._attr_supported_features = supported_features @property - def code_arm_required(self) -> bool: - """Whether the code is required for arm actions.""" - return self._code_arm_required + def state(self) -> str | None: + """Return the state of the device.""" + return self._state @callback def _update_state(self, result): diff --git a/homeassistant/components/template/binary_sensor.py b/homeassistant/components/template/binary_sensor.py index ca0ed583d86e94..427fe6221cd242 100644 --- a/homeassistant/components/template/binary_sensor.py +++ b/homeassistant/components/template/binary_sensor.py @@ -14,7 +14,6 @@ DOMAIN as BINARY_SENSOR_DOMAIN, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - BinarySensorDeviceClass, BinarySensorEntity, ) from homeassistant.config_entries import ConfigEntry @@ -236,9 +235,7 @@ def __init__( ENTITY_ID_FORMAT, object_id, hass=hass ) - self._device_class: BinarySensorDeviceClass | None = config.get( - CONF_DEVICE_CLASS - ) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._template = config[CONF_STATE] self._state: bool | None = None self._delay_cancel = None @@ -321,11 +318,6 @@ def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the binary sensor.""" - return self._device_class - class TriggerBinarySensorEntity(TriggerEntity, BinarySensorEntity, RestoreEntity): """Sensor entity based on trigger data.""" diff --git a/homeassistant/components/template/cover.py b/homeassistant/components/template/cover.py index 3a8e536f7f50f3..5daa4531109fd7 100644 --- a/homeassistant/components/template/cover.py +++ b/homeassistant/components/template/cover.py @@ -12,7 +12,6 @@ DEVICE_CLASSES_SCHEMA, ENTITY_ID_FORMAT, PLATFORM_SCHEMA, - CoverDeviceClass, CoverEntity, CoverEntityFeature, ) @@ -155,7 +154,7 @@ def __init__( self._template = config.get(CONF_VALUE_TEMPLATE) self._position_template = config.get(CONF_POSITION_TEMPLATE) self._tilt_template = config.get(CONF_TILT_TEMPLATE) - self._device_class: CoverDeviceClass | None = config.get(CONF_DEVICE_CLASS) + self._attr_device_class = config.get(CONF_DEVICE_CLASS) self._open_script = None if (open_action := config.get(OPEN_ACTION)) is not None: self._open_script = Script(hass, open_action, friendly_name, DOMAIN) @@ -182,6 +181,15 @@ def __init__( self._is_closing = False self._tilt_value = None + supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE + if self._stop_script is not None: + supported_features |= CoverEntityFeature.STOP + if self._position_script is not None: + supported_features |= CoverEntityFeature.SET_POSITION + if self._tilt_script is not None: + supported_features |= TILT_FEATURES + self._attr_supported_features = supported_features + @callback def _async_setup_templates(self) -> None: """Set up templates.""" @@ -318,27 +326,6 @@ def current_cover_tilt_position(self) -> int | None: """ return self._tilt_value - @property - def device_class(self) -> CoverDeviceClass | None: - """Return the device class of the cover.""" - return self._device_class - - @property - def supported_features(self) -> CoverEntityFeature: - """Flag supported features.""" - supported_features = CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE - - if self._stop_script is not None: - supported_features |= CoverEntityFeature.STOP - - if self._position_script is not None: - supported_features |= CoverEntityFeature.SET_POSITION - - if self._tilt_script is not None: - supported_features |= TILT_FEATURES - - return supported_features - async def async_open_cover(self, **kwargs: Any) -> None: """Move the cover up.""" if self._open_script: diff --git a/homeassistant/components/template/fan.py b/homeassistant/components/template/fan.py index c07c680887b21d..d39fa56775a625 100644 --- a/homeassistant/components/template/fan.py +++ b/homeassistant/components/template/fan.py @@ -195,6 +195,8 @@ def __init__( if self._direction_template: self._attr_supported_features |= FanEntityFeature.DIRECTION + self._attr_assumed_state = self._template is None + @property def speed_count(self) -> int: """Return the number of speeds the fan supports.""" @@ -467,8 +469,3 @@ def _update_direction(self, direction): ", ".join(_VALID_DIRECTIONS), ) self._direction = None - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None diff --git a/homeassistant/components/template/light.py b/homeassistant/components/template/light.py index 09f5054ed51cef..b3f276240b5910 100644 --- a/homeassistant/components/template/light.py +++ b/homeassistant/components/template/light.py @@ -197,6 +197,12 @@ def __init__( if len(self._supported_color_modes) == 1: self._fixed_color_mode = next(iter(self._supported_color_modes)) + self._attr_supported_features = LightEntityFeature(0) + if self._effect_script is not None: + self._attr_supported_features |= LightEntityFeature.EFFECT + if self._supports_transition is True: + self._attr_supported_features |= LightEntityFeature.TRANSITION + @property def brightness(self) -> int | None: """Return the brightness of the light.""" @@ -253,16 +259,6 @@ def supported_color_modes(self): """Flag supported color modes.""" return self._supported_color_modes - @property - def supported_features(self) -> LightEntityFeature: - """Flag supported features.""" - supported_features = LightEntityFeature(0) - if self._effect_script is not None: - supported_features |= LightEntityFeature.EFFECT - if self._supports_transition is True: - supported_features |= LightEntityFeature.TRANSITION - return supported_features - @property def is_on(self) -> bool | None: """Return true if device is on.""" @@ -644,4 +640,7 @@ def _update_supports_transition(self, render): if render in (None, "None", ""): self._supports_transition = False return + self._attr_supported_features &= LightEntityFeature.EFFECT self._supports_transition = bool(render) + if self._supports_transition: + self._attr_supported_features |= LightEntityFeature.TRANSITION diff --git a/homeassistant/components/template/lock.py b/homeassistant/components/template/lock.py index d8c7127f0e60ce..de483971ac68a1 100644 --- a/homeassistant/components/template/lock.py +++ b/homeassistant/components/template/lock.py @@ -90,11 +90,7 @@ def __init__( self._command_lock = Script(hass, config[CONF_LOCK], name, DOMAIN) self._command_unlock = Script(hass, config[CONF_UNLOCK], name, DOMAIN) self._optimistic = config.get(CONF_OPTIMISTIC) - - @property - def assumed_state(self) -> bool: - """Return true if we do optimistic updates.""" - return bool(self._optimistic) + self._attr_assumed_state = bool(self._optimistic) @property def is_locked(self) -> bool: diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index cdd14921bc1c34..e757f561a7e7e7 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -42,6 +42,7 @@ from homeassistant.helpers.trigger_template_entity import TEMPLATE_SENSOR_BASE_SCHEMA from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from . import TriggerUpdateCoordinator from .const import ( CONF_ATTRIBUTE_TEMPLATES, CONF_AVAILABILITY_TEMPLATE, @@ -274,6 +275,17 @@ class TriggerSensorEntity(TriggerEntity, RestoreSensor): domain = SENSOR_DOMAIN extra_template_keys = (CONF_STATE,) + def __init__( + self, + hass: HomeAssistant, + coordinator: TriggerUpdateCoordinator, + config: ConfigType, + ) -> None: + """Initialize.""" + super().__init__(hass, coordinator, config) + self._attr_state_class = config.get(CONF_STATE_CLASS) + self._attr_native_unit_of_measurement = config.get(CONF_UNIT_OF_MEASUREMENT) + async def async_added_to_hass(self) -> None: """Restore last state.""" await super().async_added_to_hass() @@ -293,16 +305,6 @@ def native_value(self) -> str | datetime | date | None: """Return state of the sensor.""" return self._rendered.get(CONF_STATE) - @property - def state_class(self) -> str | None: - """Sensor state class.""" - return self._config.get(CONF_STATE_CLASS) - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit of measurement of the sensor, if any.""" - return self._config.get(CONF_UNIT_OF_MEASUREMENT) - @callback def _process_data(self) -> None: """Process new data.""" diff --git a/homeassistant/components/template/switch.py b/homeassistant/components/template/switch.py index 39270d3fc6d509..5e75eafe233196 100644 --- a/homeassistant/components/template/switch.py +++ b/homeassistant/components/template/switch.py @@ -113,6 +113,7 @@ def __init__( self._on_script = Script(hass, config[ON_ACTION], friendly_name, DOMAIN) self._off_script = Script(hass, config[OFF_ACTION], friendly_name, DOMAIN) self._state: bool | None = False + self._attr_assumed_state = self._template is None @callback def _update_state(self, result): @@ -168,8 +169,3 @@ async def async_turn_off(self, **kwargs: Any) -> None: if self._template is None: self._state = False self.async_write_ha_state() - - @property - def assumed_state(self) -> bool: - """State is assumed, if no template given.""" - return self._template is None From 23a891ebb1258c336008db505a394cb007557e3c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Sep 2023 13:43:28 -0400 Subject: [PATCH 480/640] Update Roborock entity categories (#100316) --- homeassistant/components/roborock/select.py | 3 +++ homeassistant/components/roborock/sensor.py | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/homeassistant/components/roborock/select.py b/homeassistant/components/roborock/select.py index 2d76aac33d3b34..5cf71bb12f4663 100644 --- a/homeassistant/components/roborock/select.py +++ b/homeassistant/components/roborock/select.py @@ -7,6 +7,7 @@ from homeassistant.components.select import SelectEntity, SelectEntityDescription from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify @@ -43,6 +44,7 @@ class RoborockSelectDescription( translation_key="mop_intensity", api_command=RoborockCommand.SET_WATER_BOX_CUSTOM_MODE, value_fn=lambda data: data.water_box_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.water_box_mode.keys() if data.water_box_mode else None, @@ -53,6 +55,7 @@ class RoborockSelectDescription( translation_key="mop_mode", api_command=RoborockCommand.SET_MOP_MODE, value_fn=lambda data: data.mop_mode.name, + entity_category=EntityCategory.CONFIG, options_lambda=lambda data: data.mop_mode.keys() if data.mop_mode else None, parameter_lambda=lambda key, status: [status.mop_mode.as_dict().get(key)], ), diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index 8d58ae96c453d4..fc2fa6a6e40a86 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -91,6 +91,7 @@ class RoborockSensorDescription( translation_key="cleaning_time", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.status.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( native_unit_of_measurement=UnitOfTime.SECONDS, @@ -99,6 +100,7 @@ class RoborockSensorDescription( icon="mdi:history", device_class=SensorDeviceClass.DURATION, value_fn=lambda data: data.clean_summary.clean_time, + entity_category=EntityCategory.DIAGNOSTIC, ), RoborockSensorDescription( key="status", @@ -114,6 +116,7 @@ class RoborockSensorDescription( icon="mdi:texture-box", translation_key="cleaning_area", value_fn=lambda data: data.status.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( @@ -121,6 +124,7 @@ class RoborockSensorDescription( icon="mdi:texture-box", translation_key="total_cleaning_area", value_fn=lambda data: data.clean_summary.square_meter_clean_area, + entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=AREA_SQUARE_METERS, ), RoborockSensorDescription( From 7b00265cfe6ea36ba34e4dfc867a9cf0b1fc71ab Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Wed, 13 Sep 2023 20:14:03 +0200 Subject: [PATCH 481/640] Remove legacy UniFi PoE client clean up (#100318) --- homeassistant/components/unifi/__init__.py | 23 +------------ tests/components/unifi/test_switch.py | 40 +--------------------- 2 files changed, 2 insertions(+), 61 deletions(-) diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 10959b8965c88e..0bde41ac61132d 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -6,7 +6,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import config_validation as cv, entity_registry as er +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -34,9 +34,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b """Set up the UniFi Network integration.""" hass.data.setdefault(UNIFI_DOMAIN, {}) - # Removal of legacy PoE control was introduced with 2022.12 - async_remove_poe_client_entities(hass, config_entry) - try: api = await get_unifi_controller(hass, config_entry.data) controller = UniFiController(hass, config_entry, api) @@ -74,24 +71,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> return await controller.async_reset() -@callback -def async_remove_poe_client_entities( - hass: HomeAssistant, config_entry: ConfigEntry -) -> None: - """Remove PoE client entities.""" - ent_reg = er.async_get(hass) - - entity_ids_to_be_removed = [ - entry.entity_id - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - and entry.unique_id.startswith("poe-") - ] - - for entity_id in entity_ids_to_be_removed: - ent_reg.async_remove(entity_id) - - class UnifiWirelessClients: """Class to store clients known to be wireless. diff --git a/tests/components/unifi/test_switch.py b/tests/components/unifi/test_switch.py index d376cab8add0a7..8e53611929115d 100644 --- a/tests/components/unifi/test_switch.py +++ b/tests/components/unifi/test_switch.py @@ -6,7 +6,6 @@ from aiounifi.websocket import WebsocketState import pytest -from homeassistant import config_entries from homeassistant.components.switch import ( DOMAIN as SWITCH_DOMAIN, SERVICE_TURN_OFF, @@ -34,12 +33,7 @@ from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.util import dt as dt_util -from .test_controller import ( - CONTROLLER_HOST, - ENTRY_CONFIG, - SITE, - setup_unifi_integration, -) +from .test_controller import CONTROLLER_HOST, SITE, setup_unifi_integration from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -1436,38 +1430,6 @@ async def test_poe_port_switches( assert hass.states.get("switch.mock_name_port_1_poe").state == STATE_OFF -async def test_remove_poe_client_switches( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Test old PoE client switches are removed.""" - - config_entry = config_entries.ConfigEntry( - version=1, - domain=UNIFI_DOMAIN, - title="Mock Title", - data=ENTRY_CONFIG, - source="test", - options={}, - entry_id="1", - ) - - ent_reg = er.async_get(hass) - ent_reg.async_get_or_create( - SWITCH_DOMAIN, - UNIFI_DOMAIN, - "poe-123", - config_entry=config_entry, - ) - - await setup_unifi_integration(hass, aioclient_mock) - - assert not [ - entry - for entry in ent_reg.entities.values() - if entry.config_entry_id == config_entry.entry_id - ] - - async def test_wlan_switches( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, mock_unifi_websocket ) -> None: From 9631c0ba2b524cc9a2fd5ac0156dd6e9d995acfd Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 13:19:01 -0500 Subject: [PATCH 482/640] Use short hand attributes in onvif camera (#100319) see #95315 --- homeassistant/components/onvif/camera.py | 25 ++++++++---------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/onvif/camera.py b/homeassistant/components/onvif/camera.py index 96ce70344fd79d..013dd2e453f22e 100644 --- a/homeassistant/components/onvif/camera.py +++ b/homeassistant/components/onvif/camera.py @@ -113,29 +113,20 @@ def __init__(self, device: ONVIFDevice, profile: Profile) -> None: ) self._stream_uri: str | None = None self._stream_uri_future: asyncio.Future[str] | None = None + self._attr_entity_registry_enabled_default = ( + device.max_resolution == profile.video.resolution.width + ) + if profile.index: + self._attr_unique_id = f"{self.mac_or_serial}_{profile.index}" + else: + self._attr_unique_id = self.mac_or_serial + self._attr_name = f"{device.name} {profile.name}" @property def use_stream_for_stills(self) -> bool: """Whether or not to use stream to generate stills.""" return bool(self.stream and self.stream.dynamic_stream_settings.preload_stream) - @property - def name(self) -> str: - """Return the name of this camera.""" - return f"{self.device.name} {self.profile.name}" - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - if self.profile.index: - return f"{self.mac_or_serial}_{self.profile.index}" - return self.mac_or_serial - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return self.device.max_resolution == self.profile.video.resolution.width - async def stream_source(self): """Return the stream source.""" return await self._async_get_stream_uri() From 8625bf7894bd2565b9cd8687ee0446d55c0cf44a Mon Sep 17 00:00:00 2001 From: Quentame Date: Wed, 13 Sep 2023 20:22:47 +0200 Subject: [PATCH 483/640] Add some tests to Freebox (#99755) --- .../components/freebox/test_binary_sensor.py | 30 +++++------- tests/components/freebox/test_button.py | 49 +++++++++++++------ tests/components/freebox/test_sensor.py | 48 +++++++++++++++++- 3 files changed, 93 insertions(+), 34 deletions(-) diff --git a/tests/components/freebox/test_binary_sensor.py b/tests/components/freebox/test_binary_sensor.py index ec504a514adebc..218ef953ee0523 100644 --- a/tests/components/freebox/test_binary_sensor.py +++ b/tests/components/freebox/test_binary_sensor.py @@ -1,29 +1,24 @@ """Tests for the Freebox sensors.""" from copy import deepcopy -from datetime import timedelta from unittest.mock import Mock -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import CONF_HOST, CONF_PORT +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.freebox import SCAN_INTERVAL from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -import homeassistant.util.dt as dt_util -from .const import DATA_STORAGE_GET_RAIDS, MOCK_HOST, MOCK_PORT +from .common import setup_platform +from .const import DATA_STORAGE_GET_RAIDS -from tests.common import MockConfigEntry, async_fire_time_changed +from tests.common import async_fire_time_changed -async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: +async def test_raid_array_degraded( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: """Test raid array degraded binary sensor.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + await setup_platform(hass, BINARY_SENSOR_DOMAIN) assert ( hass.states.get("binary_sensor.freebox_server_r2_raid_array_0_degraded").state @@ -35,7 +30,8 @@ async def test_raid_array_degraded(hass: HomeAssistant, router: Mock) -> None: data_storage_get_raids_degraded[0]["degraded"] = True router().storage.get_raids.return_value = data_storage_get_raids_degraded # Simulate an update - async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=60)) + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) # To execute the save await hass.async_block_till_done() assert ( diff --git a/tests/components/freebox/test_button.py b/tests/components/freebox/test_button.py index de15e90f54fa92..5f72b5968f1b3d 100644 --- a/tests/components/freebox/test_button.py +++ b/tests/components/freebox/test_button.py @@ -1,29 +1,19 @@ """Tests for the Freebox config flow.""" -from unittest.mock import ANY, Mock, patch +from unittest.mock import ANY, AsyncMock, Mock, patch from pytest_unordered import unordered from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS -from homeassistant.components.freebox.const import DOMAIN -from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_PORT +from homeassistant.const import ATTR_ENTITY_ID from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component -from .const import MOCK_HOST, MOCK_PORT +from .common import setup_platform -from tests.common import MockConfigEntry - -async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: +async def test_reboot(hass: HomeAssistant, router: Mock) -> None: """Test reboot button.""" - entry = MockConfigEntry( - domain=DOMAIN, - data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, - unique_id=MOCK_HOST, - ) - entry.add_to_hass(hass) - assert await async_setup_component(hass, DOMAIN, {}) - await hass.async_block_till_done() + entry = await setup_platform(hass, BUTTON_DOMAIN) + assert hass.config_entries.async_entries() == unordered([entry, ANY]) assert router.call_count == 1 @@ -32,6 +22,7 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: with patch( "homeassistant.components.freebox.router.FreeboxRouter.reboot" ) as mock_service: + mock_service.assert_not_called() await hass.services.async_call( BUTTON_DOMAIN, SERVICE_PRESS, @@ -42,3 +33,29 @@ async def test_reboot_button(hass: HomeAssistant, router: Mock) -> None: ) await hass.async_block_till_done() mock_service.assert_called_once() + + +async def test_mark_calls_as_read(hass: HomeAssistant, router: Mock) -> None: + """Test mark calls as read button.""" + entry = await setup_platform(hass, BUTTON_DOMAIN) + + assert hass.config_entries.async_entries() == unordered([entry, ANY]) + + assert router.call_count == 1 + assert router().open.call_count == 1 + + with patch( + "homeassistant.components.freebox.router.FreeboxRouter.call" + ) as mock_service: + mock_service.mark_calls_log_as_read = AsyncMock() + mock_service.mark_calls_log_as_read.assert_not_called() + await hass.services.async_call( + BUTTON_DOMAIN, + SERVICE_PRESS, + service_data={ + ATTR_ENTITY_ID: "button.mark_calls_as_read", + }, + blocking=True, + ) + await hass.async_block_till_done() + mock_service.mark_calls_log_as_read.assert_called_once() diff --git a/tests/components/freebox/test_sensor.py b/tests/components/freebox/test_sensor.py index 41daa79fe4e00a..801e8508d86d1a 100644 --- a/tests/components/freebox/test_sensor.py +++ b/tests/components/freebox/test_sensor.py @@ -9,11 +9,57 @@ from homeassistant.core import HomeAssistant from .common import setup_platform -from .const import DATA_HOME_GET_NODES, DATA_STORAGE_GET_DISKS +from .const import ( + DATA_CONNECTION_GET_STATUS, + DATA_HOME_GET_NODES, + DATA_STORAGE_GET_DISKS, +) from tests.common import async_fire_time_changed +async def test_network_speed( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_download_speed").state == "198.9" + assert hass.states.get("sensor.freebox_upload_speed").state == "1440.0" + + # Simulate a changed speed + data_connection_get_status_changed = deepcopy(DATA_CONNECTION_GET_STATUS) + data_connection_get_status_changed["rate_down"] = 123400 + data_connection_get_status_changed["rate_up"] = 432100 + router().connection.get_status.return_value = data_connection_get_status_changed + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_download_speed").state == "123.4" + assert hass.states.get("sensor.freebox_upload_speed").state == "432.1" + + +async def test_call( + hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock +) -> None: + """Test missed call sensor.""" + await setup_platform(hass, SENSOR_DOMAIN) + + assert hass.states.get("sensor.freebox_missed_calls").state == "3" + + # Simulate we marked calls as read + data_call_get_calls_marked_as_read = [] + router().call.get_calls_log.return_value = data_call_get_calls_marked_as_read + # Simulate an update + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + # To execute the save + await hass.async_block_till_done() + assert hass.states.get("sensor.freebox_missed_calls").state == "0" + + async def test_disk( hass: HomeAssistant, freezer: FrozenDateTimeFactory, router: Mock ) -> None: From 9ceeadc7152acab0035936eefa3ef47476587272 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Wed, 13 Sep 2023 21:09:29 +0200 Subject: [PATCH 484/640] Update Mill library to 0.11.5, handle rate limiting (#100315) --- 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 a1538bed5cf832..561a24c29dfe74 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.2", "mill-local==0.3.0"] + "requirements": ["millheater==0.11.5", "mill-local==0.3.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index e077804a71826b..1c4ef579020ba0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1217,7 +1217,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.2 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 71bcf0a2a1952e..fa126b6feb81cc 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -937,7 +937,7 @@ micloud==0.5 mill-local==0.3.0 # homeassistant.components.mill -millheater==0.11.2 +millheater==0.11.5 # homeassistant.components.minio minio==7.1.12 From d8d756dd7d0275a3ef8c735fd4f5da7dfff62a95 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 14:33:42 -0500 Subject: [PATCH 485/640] Bump dbus-fast to 2.7.0 (#100321) --- 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 cd74d9b6c97efa..7908dbbad66d75 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.11.0", - "dbus-fast==2.6.0" + "dbus-fast==2.7.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index ed972c39c2c232..cf815b43b91941 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.6.0 +dbus-fast==2.7.0 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 1c4ef579020ba0..58be5cdac44463 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.6.0 +dbus-fast==2.7.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fa126b6feb81cc..82e2d21b9434d5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.6.0 +dbus-fast==2.7.0 # homeassistant.components.debugpy debugpy==1.8.0 From 877eedf6d7255da63febb88a4f5e38a5a2c65020 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 14:38:40 -0500 Subject: [PATCH 486/640] Use cached_property in entity_registry (#100302) --- homeassistant/helpers/entity_registry.py | 33 +++++++----------------- tests/syrupy.py | 5 +--- 2 files changed, 10 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 09f92a88882346..42de4749215157 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -20,6 +20,7 @@ import attr import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_FRIENDLY_NAME, @@ -148,7 +149,7 @@ def _protect_entity_options( return ReadOnlyDict({key: ReadOnlyDict(val) for key, val in data.items()}) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class RegistryEntry: """Entity Registry Entry.""" @@ -183,13 +184,6 @@ class RegistryEntry: translation_key: str | None = attr.ib(default=None) unit_of_measurement: str | None = attr.ib(default=None) - _partial_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - _display_repr: str | None | UndefinedType = attr.ib( - cmp=False, default=UNDEFINED, init=False, repr=False - ) - @domain.default def _domain_default(self) -> str: """Compute domain value.""" @@ -231,21 +225,17 @@ def _as_display_dict(self) -> dict[str, Any] | None: display_dict["dp"] = precision return display_dict - @property + @cached_property def display_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry. This version only includes what's needed for display. """ - if self._display_repr is not UNDEFINED: - return self._display_repr - try: dict_repr = self._as_display_dict json_repr: str | None = JSON_DUMP(dict_repr) if dict_repr else None - object.__setattr__(self, "_display_repr", json_repr) + return json_repr except (ValueError, TypeError): - object.__setattr__(self, "_display_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -253,8 +243,8 @@ def display_json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._display_repr # type: ignore[return-value] + + return None @property def as_partial_dict(self) -> dict[str, Any]: @@ -278,17 +268,13 @@ def as_partial_dict(self) -> dict[str, Any]: "unique_id": self.unique_id, } - @property + @cached_property def partial_json_repr(self) -> str | None: """Return a cached partial JSON representation of the entry.""" - if self._partial_repr is not UNDEFINED: - return self._partial_repr - try: dict_repr = self.as_partial_dict - object.__setattr__(self, "_partial_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): - object.__setattr__(self, "_partial_repr", None) _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", self.entity_id, @@ -296,8 +282,7 @@ def partial_json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - # Mypy doesn't understand the __setattr__ business - return self._partial_repr # type: ignore[return-value] + return None @callback def write_unavailable_state(self, hass: HomeAssistant) -> None: diff --git a/tests/syrupy.py b/tests/syrupy.py index c7d114a481266e..4846e013f5d551 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -166,7 +166,7 @@ def _serializable_entity_registry_entry( cls, data: er.RegistryEntry ) -> SerializableData: """Prepare a Home Assistant entity registry entry for serialization.""" - serialized = EntityRegistryEntrySnapshot( + return EntityRegistryEntrySnapshot( attrs.asdict(data) | { "config_entry_id": ANY, @@ -175,9 +175,6 @@ def _serializable_entity_registry_entry( "options": {k: dict(v) for k, v in data.options.items()}, } ) - serialized.pop("_partial_repr") - serialized.pop("_display_repr") - return serialized @classmethod def _serializable_flow_result(cls, data: FlowResult) -> SerializableData: From 7b204ca36b6a18bf6936d9238d5b687befd49b1c Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 22:00:29 +0200 Subject: [PATCH 487/640] Use snapshot assertion for nexia diagnostics test (#100328) --- .../nexia/snapshots/test_diagnostics.ambr | 10794 ++++++++++++++++ tests/components/nexia/test_diagnostics.py | 9107 +------------ 2 files changed, 10800 insertions(+), 9101 deletions(-) create mode 100644 tests/components/nexia/snapshots/test_diagnostics.ambr diff --git a/tests/components/nexia/snapshots/test_diagnostics.ambr b/tests/components/nexia/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..f7a7df8854b7f7 --- /dev/null +++ b/tests/components/nexia/snapshots/test_diagnostics.ambr @@ -0,0 +1,10794 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'automations': list([ + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467876', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467876', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467876', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs East Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Downstairs West Wing will permanently hold the heat to 62.0 and cool to 83.0 AND Activate the mode named 'Away 12' AND Master Suite will permanently hold the heat to 62.0 and cool to 83.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467876, + 'name': 'Away for 12 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3467870', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3467870', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3467870', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Activate the mode named 'Away 24' AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'plane', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + ]), + 'id': 3467870, + 'name': 'Away For 24 Hours', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452469', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452469', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452469', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 63.0 and cool to 80.0 AND Downstairs East Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Downstairs West Wing will permanently hold the heat to 63.0 and cool to 79.0 AND Upstairs West Wing will permanently hold the heat to 63.0 and cool to 81.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Activate the mode named 'Away Short' AND Master Suite will permanently hold the heat to 63.0 and cool to 79.0 AND Master Suite will change Fan Mode to Auto", + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'key', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452469, + 'name': 'Away Short', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3452472', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3452472', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3452472', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home' AND Master Suite will Run Schedule", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3452472, + 'name': 'Home', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454776', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454776', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454776', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs East Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Downstairs West Wing will permanently hold the heat to 60.0 and cool to 85.0 AND Upstairs West Wing will change Fan Mode to Auto AND Downstairs East Wing will change Fan Mode to Auto AND Downstairs West Wing will change Fan Mode to Auto AND Master Suite will permanently hold the heat to 60.0 and cool to 85.0 AND Master Suite will change Fan Mode to Auto', + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454776, + 'name': 'IFTTT Power Spike', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3454774', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3454774', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3454774', + }), + }), + 'description': 'When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Master Suite will Run Schedule', + 'enabled': False, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + ]), + 'id': 3454774, + 'name': 'IFTTT return to schedule', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486078', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486078', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486078', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs East Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Downstairs West Wing will permanently hold the heat to 55.0 and cool to 90.0 AND Activate the mode named 'Power Outage'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'climate', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'bell', + }), + ]), + 'id': 3486078, + 'name': 'Power Outage', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + dict({ + '_links': dict({ + 'edit': dict({ + 'href': 'https://www.mynexia.com/mobile/automation_edit_buffers?automation_id=3486091', + 'method': 'POST', + }), + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?automation_id=3486091', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/automations/3486091', + }), + }), + 'description': "When IFTTT activates the automation Upstairs West Wing will Run Schedule AND Downstairs East Wing will Run Schedule AND Downstairs West Wing will Run Schedule AND Activate the mode named 'Home'", + 'enabled': True, + 'icon': list([ + dict({ + 'modifiers': list([ + ]), + 'name': 'gears', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'settings', + }), + dict({ + 'modifiers': list([ + ]), + 'name': 'at_home', + }), + ]), + 'id': 3486091, + 'name': 'Power Restored', + 'settings': list([ + ]), + 'triggers': list([ + ]), + }), + ]), + 'devices': list([ + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059661', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '000000', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059661?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059661, + 'indoor_humidity': '36', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs East Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059661/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 71, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261002', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261002', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261002&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-71', + ]), + 'name': 'thermostat', + }), + 'id': 83261002, + 'name': 'Living East', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261002/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 71, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261005', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261005', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261005&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83261005, + 'name': 'Kitchen', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261005/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261008', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261008', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261008&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83261008, + 'name': 'Down Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261008/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 78, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261011', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261011', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261011&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-78', + ]), + 'name': 'thermostat', + }), + 'id': 83261011, + 'name': 'Tech Room', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261011/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 78, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059676', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853E08', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059676?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059676, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Downstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '88', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059676/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261015', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261015', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261015&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261015, + 'name': 'Living West', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261015/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83261018', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83261018', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83261018&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83261018, + 'name': 'David Office', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83261018/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2293892', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '0281B02C', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Cooling', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_on', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.69, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2293892?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2293892, + 'indoor_humidity': '52', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Master Suite', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/dehumidify', + }), + }), + 'current_value': 0.45, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2293892/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'Cooling', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394133', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394133', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394133&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394133, + 'name': 'Bath Closet', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394133/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130', + }), + }), + 'cooling_setpoint': 71, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 71, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Open', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394130', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394130', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394130&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394130, + 'name': 'Master', + 'operating_state': 'Damper Open', + 'setpoints': dict({ + 'cool': 71, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394130/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Open', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Relieving Air', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 73, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394136', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394136', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394136&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-73', + ]), + 'name': 'thermostat', + }), + 'id': 83394136, + 'name': 'Nick Office', + 'operating_state': 'Relieving Air', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394136/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 73, + 'type': 'xxl_zone', + 'zone_status': 'Relieving Air', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 72, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394127', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394127', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394127&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-72', + ]), + 'name': 'thermostat', + }), + 'id': 83394127, + 'name': 'Snooze Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394127/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 72, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139', + }), + }), + 'cooling_setpoint': 79, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 79, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'Damper Closed', + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'cooling', + }), + 'system_status': 'Cooling', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83394139', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83394139', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83394139&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83394139, + 'name': 'Safe Room', + 'operating_state': 'Damper Closed', + 'setpoints': dict({ + 'cool': 79, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83394139/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': 'Damper Closed', + }), + ]), + }), + dict({ + '_links': dict({ + 'filter_events': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb', + }), + 'nexia:history': dict({ + 'href': 'https://www.mynexia.com/mobile/houses/123456/events?device_id=2059652', + }), + 'pending_request': dict({ + 'polling_path': 'https://www.mynexia.com/backstage/announcements/c6627726f6339d104ee66897028d6a2ea38215675b336650', + }), + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652', + }), + }), + 'connected': True, + 'delta': 3, + 'features': list([ + dict({ + 'items': list([ + dict({ + 'label': 'Model', + 'type': 'label_value', + 'value': 'XL1050', + }), + dict({ + 'label': 'AUID', + 'type': 'label_value', + 'value': '02853DF0', + }), + dict({ + 'label': 'Firmware Build Number', + 'type': 'label_value', + 'value': '1581321824', + }), + dict({ + 'label': 'Firmware Build Date', + 'type': 'label_value', + 'value': '2020-02-10 08:03:44 UTC', + }), + dict({ + 'label': 'Firmware Version', + 'type': 'label_value', + 'value': '5.9.1', + }), + dict({ + 'label': 'Zoning Enabled', + 'type': 'label_value', + 'value': 'yes', + }), + ]), + 'name': 'advanced_info', + }), + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': 'System Idle', + 'status_icon': None, + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'members': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + 'name': 'group', + }), + dict({ + 'actions': dict({ + 'update_thermostat_fan_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Fan Mode', + 'name': 'thermostat_fan_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_fan_mode', + 'label': 'Fan Mode', + 'value': 'thermostat_fan_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'status_icon': dict({ + 'modifiers': list([ + ]), + 'name': 'thermostat_fan_off', + }), + 'value': 'auto', + }), + dict({ + 'compressor_speed': 0.0, + 'name': 'thermostat_compressor_speed', + }), + dict({ + 'actions': dict({ + 'get_monthly_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=monthly', + 'method': 'GET', + }), + 'get_runtime_history': dict({ + 'href': 'https://www.mynexia.com/mobile/runtime_history/2059652?report_type=daily', + 'method': 'GET', + }), + }), + 'name': 'runtime_history', + }), + ]), + 'has_indoor_humidity': True, + 'has_outdoor_temperature': True, + 'icon': list([ + dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + ]), + 'id': 2059652, + 'indoor_humidity': '37', + 'last_updated_at': '2020-03-11T15:15:53.000-05:00', + 'name': 'Upstairs West Wing', + 'name_editable': True, + 'outdoor_temperature': '87', + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'On', + 'Circulate', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'On', + 'value': 'on', + }), + dict({ + 'label': 'Circulate', + 'value': 'circulate', + }), + ]), + 'title': 'Fan Mode', + 'type': 'fan_mode', + 'values': list([ + 'auto', + 'on', + 'circulate', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_speed', + }), + }), + 'current_value': 0.35, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + '70%', + '75%', + '80%', + '85%', + '90%', + '95%', + '100%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + dict({ + 'label': '70%', + 'value': 0.7, + }), + dict({ + 'label': '75%', + 'value': 0.75, + }), + dict({ + 'label': '80%', + 'value': 0.8, + }), + dict({ + 'label': '85%', + 'value': 0.85, + }), + dict({ + 'label': '90%', + 'value': 0.9, + }), + dict({ + 'label': '95%', + 'value': 0.95, + }), + dict({ + 'label': '100%', + 'value': 1.0, + }), + ]), + 'title': 'Fan Speed', + 'type': 'fan_speed', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + 0.7, + 0.75, + 0.8, + 0.85, + 0.9, + 0.95, + 1.0, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/fan_circulation_time', + }), + }), + 'current_value': 30, + 'labels': list([ + '10 minutes', + '15 minutes', + '20 minutes', + '25 minutes', + '30 minutes', + '35 minutes', + '40 minutes', + '45 minutes', + '50 minutes', + '55 minutes', + ]), + 'options': list([ + dict({ + 'label': '10 minutes', + 'value': 10, + }), + dict({ + 'label': '15 minutes', + 'value': 15, + }), + dict({ + 'label': '20 minutes', + 'value': 20, + }), + dict({ + 'label': '25 minutes', + 'value': 25, + }), + dict({ + 'label': '30 minutes', + 'value': 30, + }), + dict({ + 'label': '35 minutes', + 'value': 35, + }), + dict({ + 'label': '40 minutes', + 'value': 40, + }), + dict({ + 'label': '45 minutes', + 'value': 45, + }), + dict({ + 'label': '50 minutes', + 'value': 50, + }), + dict({ + 'label': '55 minutes', + 'value': 55, + }), + ]), + 'title': 'Fan Circulation Time', + 'type': 'fan_circulation_time', + 'values': list([ + 10, + 15, + 20, + 25, + 30, + 35, + 40, + 45, + 50, + 55, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/air_cleaner_mode', + }), + }), + 'current_value': 'auto', + 'labels': list([ + 'Auto', + 'Quick', + 'Allergy', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'auto', + }), + dict({ + 'label': 'Quick', + 'value': 'quick', + }), + dict({ + 'label': 'Allergy', + 'value': 'allergy', + }), + ]), + 'title': 'Air Cleaner Mode', + 'type': 'air_cleaner_mode', + 'values': list([ + 'auto', + 'quick', + 'allergy', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/dehumidify', + }), + }), + 'current_value': 0.5, + 'labels': list([ + '35%', + '40%', + '45%', + '50%', + '55%', + '60%', + '65%', + ]), + 'options': list([ + dict({ + 'label': '35%', + 'value': 0.35, + }), + dict({ + 'label': '40%', + 'value': 0.4, + }), + dict({ + 'label': '45%', + 'value': 0.45, + }), + dict({ + 'label': '50%', + 'value': 0.5, + }), + dict({ + 'label': '55%', + 'value': 0.55, + }), + dict({ + 'label': '60%', + 'value': 0.6, + }), + dict({ + 'label': '65%', + 'value': 0.65, + }), + ]), + 'title': 'Cooling Dehumidify Set Point', + 'type': 'dehumidify', + 'values': list([ + 0.35, + 0.4, + 0.45, + 0.5, + 0.55, + 0.6, + 0.65, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_thermostats/2059652/scale', + }), + }), + 'current_value': 'f', + 'labels': list([ + 'F', + 'C', + ]), + 'options': list([ + dict({ + 'label': 'F', + 'value': 'f', + }), + dict({ + 'label': 'C', + 'value': 'c', + }), + ]), + 'title': 'Temperature Scale', + 'type': 'scale', + 'values': list([ + 'f', + 'c', + ]), + }), + ]), + 'status_secondary': None, + 'status_tertiary': None, + 'system_status': 'System Idle', + 'type': 'xxl_thermostat', + 'zones': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991', + }), + }), + 'cooling_setpoint': 80, + 'current_zone_mode': 'OFF', + 'features': list([ + dict({ + 'actions': dict({ + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 77, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Off', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'OFF', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260991', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260991', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260991&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-77', + ]), + 'name': 'thermostat', + }), + 'id': 83260991, + 'name': 'Hallway', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 80, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/zone_mode', + }), + }), + 'current_value': 'OFF', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260991/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 77, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 74, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260994', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260994', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260994&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-74', + ]), + 'name': 'thermostat', + }), + 'id': 83260994, + 'name': 'Mid Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260994/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 74, + 'type': 'xxl_zone', + 'zone_status': '', + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997', + }), + }), + 'cooling_setpoint': 81, + 'current_zone_mode': 'AUTO', + 'features': list([ + dict({ + 'actions': dict({ + 'set_cool_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + 'set_heat_setpoint': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/setpoints', + }), + }), + 'name': 'thermostat', + 'scale': 'f', + 'setpoint_cool': 81, + 'setpoint_cool_max': 99, + 'setpoint_cool_min': 60, + 'setpoint_delta': 3, + 'setpoint_heat': 63, + 'setpoint_heat_max': 90, + 'setpoint_heat_min': 55, + 'setpoint_increment': 1.0, + 'status': '', + 'status_icon': None, + 'system_status': 'System Idle', + 'temperature': 75, + }), + dict({ + 'is_connected': True, + 'name': 'connection', + 'signal_strength': 'unknown', + }), + dict({ + 'actions': dict({ + 'update_thermostat_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Auto', + 'label': 'Zone Mode', + 'name': 'thermostat_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_mode', + 'label': 'Zone Mode', + 'value': 'thermostat_mode', + }), + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'value': 'AUTO', + }), + dict({ + 'actions': dict({ + 'update_thermostat_run_mode': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + 'method': 'POST', + }), + }), + 'display_value': 'Hold', + 'label': 'Run Mode', + 'name': 'thermostat_run_mode', + 'options': list([ + dict({ + 'header': True, + 'id': 'thermostat_run_mode', + 'label': 'Run Mode', + 'value': 'thermostat_run_mode', + }), + dict({ + 'id': 'info_text', + 'info': True, + 'label': 'Follow or override the schedule.', + 'value': 'info_text', + }), + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'value': 'permanent_hold', + }), + dict({ + 'actions': dict({ + 'enable_scheduling': dict({ + 'data': dict({ + 'value': True, + }), + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + 'method': 'POST', + }), + 'get_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + 'get_default_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/get_default_schedule?device_identifier=XxlZone-83260997', + 'method': 'GET', + }), + 'set_active_schedule': dict({ + 'href': 'https://www.mynexia.com/mobile/thermostat_schedules/set_active_schedule?device_identifier=XxlZone-83260997', + 'method': 'POST', + }), + }), + 'can_add_remove_periods': True, + 'collection_url': 'https://www.mynexia.com/mobile/schedules?device_identifier=XxlZone-83260997&house_id=123456', + 'enabled': True, + 'max_period_name_length': 10, + 'max_periods_per_day': 4, + 'name': 'schedule', + 'setpoint_increment': 1, + }), + ]), + 'heating_setpoint': 63, + 'icon': dict({ + 'modifiers': list([ + 'temperature-75', + ]), + 'name': 'thermostat', + }), + 'id': 83260997, + 'name': 'West Bedroom', + 'operating_state': '', + 'setpoints': dict({ + 'cool': 81, + 'heat': 63, + }), + 'settings': list([ + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/preset_selected', + }), + }), + 'current_value': 0, + 'labels': list([ + 'None', + 'Home', + 'Away', + 'Sleep', + ]), + 'options': list([ + dict({ + 'label': 'None', + 'value': 0, + }), + dict({ + 'label': 'Home', + 'value': 1, + }), + dict({ + 'label': 'Away', + 'value': 2, + }), + dict({ + 'label': 'Sleep', + 'value': 3, + }), + ]), + 'title': 'Preset', + 'type': 'preset_selected', + 'values': list([ + 0, + 1, + 2, + 3, + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/zone_mode', + }), + }), + 'current_value': 'AUTO', + 'labels': list([ + 'Auto', + 'Cooling', + 'Heating', + 'Off', + ]), + 'options': list([ + dict({ + 'label': 'Auto', + 'value': 'AUTO', + }), + dict({ + 'label': 'Cooling', + 'value': 'COOL', + }), + dict({ + 'label': 'Heating', + 'value': 'HEAT', + }), + dict({ + 'label': 'Off', + 'value': 'OFF', + }), + ]), + 'title': 'Zone Mode', + 'type': 'zone_mode', + 'values': list([ + 'AUTO', + 'COOL', + 'HEAT', + 'OFF', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/run_mode', + }), + }), + 'current_value': 'permanent_hold', + 'labels': list([ + 'Permanent Hold', + 'Run Schedule', + ]), + 'options': list([ + dict({ + 'label': 'Permanent Hold', + 'value': 'permanent_hold', + }), + dict({ + 'label': 'Run Schedule', + 'value': 'run_schedule', + }), + ]), + 'title': 'Run Mode', + 'type': 'run_mode', + 'values': list([ + 'permanent_hold', + 'run_schedule', + ]), + }), + dict({ + '_links': dict({ + 'self': dict({ + 'href': 'https://www.mynexia.com/mobile/xxl_zones/83260997/scheduling_enabled', + }), + }), + 'current_value': True, + 'labels': list([ + 'ON', + 'OFF', + ]), + 'options': list([ + dict({ + 'label': 'ON', + 'value': True, + }), + dict({ + 'label': 'OFF', + 'value': False, + }), + ]), + 'title': 'Scheduling', + 'type': 'scheduling_enabled', + 'values': list([ + True, + False, + ]), + }), + ]), + 'temperature': 75, + 'type': 'xxl_zone', + 'zone_status': '', + }), + ]), + }), + ]), + 'entry': dict({ + 'brand': None, + 'title': 'Mock Title', + }), + }) +# --- diff --git a/tests/components/nexia/test_diagnostics.py b/tests/components/nexia/test_diagnostics.py index f58574098cc98b..9f8f7f05a8df9b 100644 --- a/tests/components/nexia/test_diagnostics.py +++ b/tests/components/nexia/test_diagnostics.py @@ -1,4 +1,6 @@ """Test august diagnostics.""" +from syrupy import SnapshotAssertion + from homeassistant.core import HomeAssistant from .util import async_init_integration @@ -8,9109 +10,12 @@ async def test_diagnostics( - hass: HomeAssistant, hass_client: ClientSessionGenerator + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + snapshot: SnapshotAssertion, ) -> None: """Test generating diagnostics for a config entry.""" entry = await async_init_integration(hass) diag = await get_diagnostics_for_config_entry(hass, hass_client, entry) - assert diag == { - "automations": [ - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467876" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=472ae0d2-5d7c-4a1c-9e47-4d9035fdace5" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "?automation_id=3467876" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467876" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "62.0 and cool to 83.0 AND Downstairs East " - "Wing will permanently hold the heat to 62.0 " - "and cool to 83.0 AND Downstairs West Wing " - "will permanently hold the heat to 62.0 and " - "cool to 83.0 AND Activate the mode named " - "'Away 12' AND Master Suite will permanently " - "hold the heat to 62.0 and cool to 83.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467876, - "name": "Away for 12 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3467870" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=f63ee20c-3146-49a1-87c5-47429a063d15" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3467870" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3467870" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Activate the mode named " - "'Away 24' AND Master Suite will permanently " - "hold the heat to 60.0 and cool to 85.0" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "plane"}, - {"modifiers": [], "name": "climate"}, - ], - "id": 3467870, - "name": "Away For 24 Hours", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452469" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e5c59b93-efca-4937-9499-3f4c896ab17c" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452469" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452469" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "63.0 and cool to 80.0 AND Downstairs East " - "Wing will permanently hold the heat to 63.0 " - "and cool to 79.0 AND Downstairs West Wing " - "will permanently hold the heat to 63.0 and " - "cool to 79.0 AND Upstairs West Wing will " - "permanently hold the heat to 63.0 and cool " - "to 81.0 AND Upstairs West Wing will change " - "Fan Mode to Auto AND Downstairs East Wing " - "will change Fan Mode to Auto AND Downstairs " - "West Wing will change Fan Mode to Auto AND " - "Activate the mode named 'Away Short' AND " - "Master Suite will permanently hold the heat " - "to 63.0 and cool to 79.0 AND Master Suite " - "will change Fan Mode to Auto" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "key"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452469, - "name": "Away Short", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3452472" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=861b9fec-d259-4492-a798-5712251666c4" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3452472" - ), - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3452472" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home' AND Master Suite will Run " - "Schedule" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3452472, - "name": "Home", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454776" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=96c71d37-66aa-4cbb-84ff-a90412fd366a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454776" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454776" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "60.0 and cool to 85.0 AND Downstairs East " - "Wing will permanently hold the heat to 60.0 " - "and cool to 85.0 AND Downstairs West Wing " - "will permanently hold the heat to 60.0 and " - "cool to 85.0 AND Upstairs West Wing will " - "change Fan Mode to Auto AND Downstairs East " - "Wing will change Fan Mode to Auto AND " - "Downstairs West Wing will change Fan Mode to " - "Auto AND Master Suite will permanently hold " - "the heat to 60.0 and cool to 85.0 AND Master " - "Suite will change Fan Mode to Auto" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454776, - "name": "IFTTT Power Spike", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3454774" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=880c5287-d92c-4368-8494-e10975e92733" - ), - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3454774" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3454774" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Master Suite " - "will Run Schedule" - ), - "enabled": False, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - ], - "id": 3454774, - "name": "IFTTT return to schedule", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486078" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=d33c013b-2357-47a9-8c66-d2c3693173b0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486078" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486078" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will permanently hold the heat to " - "55.0 and cool to 90.0 AND Downstairs East " - "Wing will permanently hold the heat to 55.0 " - "and cool to 90.0 AND Downstairs West Wing " - "will permanently hold the heat to 55.0 and " - "cool to 90.0 AND Activate the mode named " - "'Power Outage'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "climate"}, - {"modifiers": [], "name": "bell"}, - ], - "id": 3486078, - "name": "Power Outage", - "settings": [], - "triggers": [], - }, - { - "_links": { - "edit": { - "href": ( - "https://www.mynexia.com/mobile" - "/automation_edit_buffers?automation_id=3486091" - ), - "method": "POST", - }, - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=b9141df8-2e5e-4524-b8ef-efcbf48d775a" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?automation_id=3486091" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/automations/3486091" - }, - }, - "description": ( - "When IFTTT activates the automation Upstairs " - "West Wing will Run Schedule AND Downstairs " - "East Wing will Run Schedule AND Downstairs " - "West Wing will Run Schedule AND Activate the " - "mode named 'Home'" - ), - "enabled": True, - "icon": [ - {"modifiers": [], "name": "gears"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "settings"}, - {"modifiers": [], "name": "at_home"}, - ], - "id": 3486091, - "name": "Power Restored", - "settings": [], - "triggers": [], - }, - ], - "devices": [ - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=cd9a70e8-fd0d-4b58-b071-05a202fd8953" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059661" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/be6d8ede5cac02fe8be18c334b04d539c9200fa9230eef63" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059661" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - {"label": "AUID", "type": "label_value", "value": "000000"}, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-71"], - "name": "thermostat", - }, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile" - "/schedules" - "?device_identifier" - "=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-78"], - "name": "thermostat", - }, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261011" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile" - "/runtime_history/2059661?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-71"], "name": "thermostat"}, - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-78"], "name": "thermostat"}, - ], - "id": 2059661, - "indoor_humidity": "36", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs East Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059661/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261002" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 71, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261002" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261002" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-71"], "name": "thermostat"}, - "id": 83261002, - "name": "Living East", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261002/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261002/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 71, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261005" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261005/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83261005" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261005" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83261005, - "name": "Kitchen", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261005/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261008" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261008/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261008" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261008" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83261008, - "name": "Down Bedroom", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261008/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261011" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 78, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261011" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261011" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-78"], "name": "thermostat"}, - "id": 83261011, - "name": "Tech Room", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261011/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 78, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=5aae72a6-1bd0-4d84-9bfd-673e7bc4907c" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059676" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/3412f1d96eb0c5edb5466c3c0598af60c06f8443f21e9bcb" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059676" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853E08", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059676?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-75"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059676, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Downstairs West Wing", - "name_editable": True, - "outdoor_temperature": "88", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2059676/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261015" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261015" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261015" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261015, - "name": "Living West", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261015/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261015/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83261018" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83261018/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83261018" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83261018" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83261018, - "name": "David Office", - "operating_state": "", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83261018/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=e3fc90c7-2885-4f57-ae76-99e9ec81eef0" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2293892" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/967361e8aed874aa5230930fd0e0bbd8b653261e982a6e0e" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2293892" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "0281B02C", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Cooling", - "status_icon": {"modifiers": [], "name": "cooling"}, - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-73"], - "name": "thermostat", - }, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-72"], - "name": "thermostat", - }, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": { - "modifiers": [], - "name": "cooling", - }, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_on"}, - "value": "auto", - }, - {"compressor_speed": 0.69, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2293892?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-73"], "name": "thermostat"}, - {"modifiers": ["temperature-72"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - ], - "id": 2293892, - "indoor_humidity": "52", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Master Suite", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_thermostats" - "/2293892/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/dehumidify" - ) - } - }, - "current_value": 0.45, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2293892/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "Cooling", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394133" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394133/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394133" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394133" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394133, - "name": "Bath Closet", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394133/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394130" - ) - } - }, - "cooling_setpoint": 71, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 71, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Open", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394130" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394130" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394130, - "name": "Master", - "operating_state": "Damper Open", - "setpoints": {"cool": 71, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394130" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Open", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394136" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Relieving Air", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 73, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83394136" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394136" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-73"], "name": "thermostat"}, - "id": 83394136, - "name": "Nick Office", - "operating_state": "Relieving Air", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394136/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 73, - "type": "xxl_zone", - "zone_status": "Relieving Air", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394127" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 72, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394127/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394127" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394127" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-72"], "name": "thermostat"}, - "id": 83394127, - "name": "Snooze Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394127/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 72, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83394139" - ) - } - }, - "cooling_setpoint": 79, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 79, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "Damper Closed", - "status_icon": {"modifiers": [], "name": "cooling"}, - "system_status": "Cooling", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83394139/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83394139" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83394139" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83394139, - "name": "Safe Room", - "operating_state": "Damper Closed", - "setpoints": {"cool": 79, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83394139/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "Damper Closed", - }, - ], - }, - { - "_links": { - "filter_events": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456/events" - "/collection?sys_guid=3679e95b-7337-48ae-aff4-e0522e9dd0eb" - ) - }, - "nexia:history": { - "href": ( - "https://www.mynexia.com/mobile/houses/123456" - "/events?device_id=2059652" - ) - }, - "pending_request": { - "polling_path": ( - "https://www.mynexia.com/backstage/announcements" - "/c6627726f6339d104ee66897028d6a2ea38215675b336650" - ) - }, - "self": { - "href": "https://www.mynexia.com/mobile/xxl_thermostats/2059652" - }, - }, - "connected": True, - "delta": 3, - "features": [ - { - "items": [ - { - "label": "Model", - "type": "label_value", - "value": "XL1050", - }, - { - "label": "AUID", - "type": "label_value", - "value": "02853DF0", - }, - { - "label": "Firmware Build Number", - "type": "label_value", - "value": "1581321824", - }, - { - "label": "Firmware Build Date", - "type": "label_value", - "value": "2020-02-10 08:03:44 UTC", - }, - { - "label": "Firmware Version", - "type": "label_value", - "value": "5.9.1", - }, - { - "label": "Zoning Enabled", - "type": "label_value", - "value": "yes", - }, - ], - "name": "advanced_info", - }, - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "System Idle", - "status_icon": None, - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "members": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-77"], - "name": "thermostat", - }, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-74"], - "name": "thermostat", - }, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260994" - "/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": ( - "Follow or override the schedule." - ), - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997" - "/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier" - "=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": { - "modifiers": ["temperature-75"], - "name": "thermostat", - }, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": [ - "Permanent Hold", - "Run Schedule", - ], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - { - "label": "Run Schedule", - "value": "run_schedule", - }, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - "name": "group", - }, - { - "actions": { - "update_thermostat_fan_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Fan Mode", - "name": "thermostat_fan_mode", - "options": [ - { - "header": True, - "id": "thermostat_fan_mode", - "label": "Fan Mode", - "value": "thermostat_fan_mode", - }, - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "status_icon": {"modifiers": [], "name": "thermostat_fan_off"}, - "value": "auto", - }, - {"compressor_speed": 0.0, "name": "thermostat_compressor_speed"}, - { - "actions": { - "get_monthly_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=monthly" - ), - "method": "GET", - }, - "get_runtime_history": { - "href": ( - "https://www.mynexia.com/mobile/runtime_history" - "/2059652?report_type=daily" - ), - "method": "GET", - }, - }, - "name": "runtime_history", - }, - ], - "has_indoor_humidity": True, - "has_outdoor_temperature": True, - "icon": [ - {"modifiers": ["temperature-77"], "name": "thermostat"}, - {"modifiers": ["temperature-74"], "name": "thermostat"}, - {"modifiers": ["temperature-75"], "name": "thermostat"}, - ], - "id": 2059652, - "indoor_humidity": "37", - "last_updated_at": "2020-03-11T15:15:53.000-05:00", - "name": "Upstairs West Wing", - "name_editable": True, - "outdoor_temperature": "87", - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "On", "Circulate"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "On", "value": "on"}, - {"label": "Circulate", "value": "circulate"}, - ], - "title": "Fan Mode", - "type": "fan_mode", - "values": ["auto", "on", "circulate"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/fan_speed" - ) - } - }, - "current_value": 0.35, - "labels": [ - "35%", - "40%", - "45%", - "50%", - "55%", - "60%", - "65%", - "70%", - "75%", - "80%", - "85%", - "90%", - "95%", - "100%", - ], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - {"label": "70%", "value": 0.7}, - {"label": "75%", "value": 0.75}, - {"label": "80%", "value": 0.8}, - {"label": "85%", "value": 0.85}, - {"label": "90%", "value": 0.9}, - {"label": "95%", "value": 0.95}, - {"label": "100%", "value": 1.0}, - ], - "title": "Fan Speed", - "type": "fan_speed", - "values": [ - 0.35, - 0.4, - 0.45, - 0.5, - 0.55, - 0.6, - 0.65, - 0.7, - 0.75, - 0.8, - 0.85, - 0.9, - 0.95, - 1.0, - ], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652" - "/fan_circulation_time" - ) - } - }, - "current_value": 30, - "labels": [ - "10 minutes", - "15 minutes", - "20 minutes", - "25 minutes", - "30 minutes", - "35 minutes", - "40 minutes", - "45 minutes", - "50 minutes", - "55 minutes", - ], - "options": [ - {"label": "10 minutes", "value": 10}, - {"label": "15 minutes", "value": 15}, - {"label": "20 minutes", "value": 20}, - {"label": "25 minutes", "value": 25}, - {"label": "30 minutes", "value": 30}, - {"label": "35 minutes", "value": 35}, - {"label": "40 minutes", "value": 40}, - {"label": "45 minutes", "value": 45}, - {"label": "50 minutes", "value": 50}, - {"label": "55 minutes", "value": 55}, - ], - "title": "Fan Circulation Time", - "type": "fan_circulation_time", - "values": [10, 15, 20, 25, 30, 35, 40, 45, 50, 55], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/air_cleaner_mode" - ) - } - }, - "current_value": "auto", - "labels": ["Auto", "Quick", "Allergy"], - "options": [ - {"label": "Auto", "value": "auto"}, - {"label": "Quick", "value": "quick"}, - {"label": "Allergy", "value": "allergy"}, - ], - "title": "Air Cleaner Mode", - "type": "air_cleaner_mode", - "values": ["auto", "quick", "allergy"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/dehumidify" - ) - } - }, - "current_value": 0.5, - "labels": ["35%", "40%", "45%", "50%", "55%", "60%", "65%"], - "options": [ - {"label": "35%", "value": 0.35}, - {"label": "40%", "value": 0.4}, - {"label": "45%", "value": 0.45}, - {"label": "50%", "value": 0.5}, - {"label": "55%", "value": 0.55}, - {"label": "60%", "value": 0.6}, - {"label": "65%", "value": 0.65}, - ], - "title": "Cooling Dehumidify Set Point", - "type": "dehumidify", - "values": [0.35, 0.4, 0.45, 0.5, 0.55, 0.6, 0.65], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_thermostats/2059652/scale" - ) - } - }, - "current_value": "f", - "labels": ["F", "C"], - "options": [ - {"label": "F", "value": "f"}, - {"label": "C", "value": "c"}, - ], - "title": "Temperature Scale", - "type": "scale", - "values": ["f", "c"], - }, - ], - "status_secondary": None, - "status_tertiary": None, - "system_status": "System Idle", - "type": "xxl_thermostat", - "zones": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260991" - ) - } - }, - "cooling_setpoint": 80, - "current_zone_mode": "OFF", - "features": [ - { - "actions": {}, - "name": "thermostat", - "scale": "f", - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 77, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Off", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "OFF", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/get_default_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules/set_active_schedule" - "?device_identifier=XxlZone-83260991" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260991" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-77"], "name": "thermostat"}, - "id": 83260991, - "name": "Hallway", - "operating_state": "", - "setpoints": {"cool": 80, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/zone_mode" - ) - } - }, - "current_value": "OFF", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260991/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 77, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260994" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 74, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260994" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260994" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-74"], "name": "thermostat"}, - "id": 83260994, - "name": "Mid Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260994/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 74, - "type": "xxl_zone", - "zone_status": "", - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones/83260997" - ) - } - }, - "cooling_setpoint": 81, - "current_zone_mode": "AUTO", - "features": [ - { - "actions": { - "set_cool_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - "set_heat_setpoint": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/setpoints" - ) - }, - }, - "name": "thermostat", - "scale": "f", - "setpoint_cool": 81, - "setpoint_cool_max": 99, - "setpoint_cool_min": 60, - "setpoint_delta": 3, - "setpoint_heat": 63, - "setpoint_heat_max": 90, - "setpoint_heat_min": 55, - "setpoint_increment": 1.0, - "status": "", - "status_icon": None, - "system_status": "System Idle", - "temperature": 75, - }, - { - "is_connected": True, - "name": "connection", - "signal_strength": "unknown", - }, - { - "actions": { - "update_thermostat_mode": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ), - "method": "POST", - } - }, - "display_value": "Auto", - "label": "Zone Mode", - "name": "thermostat_mode", - "options": [ - { - "header": True, - "id": "thermostat_mode", - "label": "Zone Mode", - "value": "thermostat_mode", - }, - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "value": "AUTO", - }, - { - "actions": { - "update_thermostat_run_mode": { - "href": ( - "https://www.mynexia.com/mobile" - "/xxl_zones/83260997/run_mode" - ), - "method": "POST", - } - }, - "display_value": "Hold", - "label": "Run Mode", - "name": "thermostat_run_mode", - "options": [ - { - "header": True, - "id": "thermostat_run_mode", - "label": "Run Mode", - "value": "thermostat_run_mode", - }, - { - "id": "info_text", - "info": True, - "label": "Follow or override the schedule.", - "value": "info_text", - }, - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "value": "permanent_hold", - }, - { - "actions": { - "enable_scheduling": { - "data": {"value": True}, - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ), - "method": "POST", - }, - "get_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - "get_default_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/get_default_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "GET", - }, - "set_active_schedule": { - "href": ( - "https://www.mynexia.com/mobile" - "/thermostat_schedules" - "/set_active_schedule" - "?device_identifier=XxlZone-83260997" - ), - "method": "POST", - }, - }, - "can_add_remove_periods": True, - "collection_url": ( - "https://www.mynexia.com/mobile/schedules" - "?device_identifier=XxlZone-83260997" - "&house_id=123456" - ), - "enabled": True, - "max_period_name_length": 10, - "max_periods_per_day": 4, - "name": "schedule", - "setpoint_increment": 1, - }, - ], - "heating_setpoint": 63, - "icon": {"modifiers": ["temperature-75"], "name": "thermostat"}, - "id": 83260997, - "name": "West Bedroom", - "operating_state": "", - "setpoints": {"cool": 81, "heat": 63}, - "settings": [ - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/preset_selected" - ) - } - }, - "current_value": 0, - "labels": ["None", "Home", "Away", "Sleep"], - "options": [ - {"label": "None", "value": 0}, - {"label": "Home", "value": 1}, - {"label": "Away", "value": 2}, - {"label": "Sleep", "value": 3}, - ], - "title": "Preset", - "type": "preset_selected", - "values": [0, 1, 2, 3], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/zone_mode" - ) - } - }, - "current_value": "AUTO", - "labels": ["Auto", "Cooling", "Heating", "Off"], - "options": [ - {"label": "Auto", "value": "AUTO"}, - {"label": "Cooling", "value": "COOL"}, - {"label": "Heating", "value": "HEAT"}, - {"label": "Off", "value": "OFF"}, - ], - "title": "Zone Mode", - "type": "zone_mode", - "values": ["AUTO", "COOL", "HEAT", "OFF"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/run_mode" - ) - } - }, - "current_value": "permanent_hold", - "labels": ["Permanent Hold", "Run Schedule"], - "options": [ - { - "label": "Permanent Hold", - "value": "permanent_hold", - }, - {"label": "Run Schedule", "value": "run_schedule"}, - ], - "title": "Run Mode", - "type": "run_mode", - "values": ["permanent_hold", "run_schedule"], - }, - { - "_links": { - "self": { - "href": ( - "https://www.mynexia.com/mobile/xxl_zones" - "/83260997/scheduling_enabled" - ) - } - }, - "current_value": True, - "labels": ["ON", "OFF"], - "options": [ - {"label": "ON", "value": True}, - {"label": "OFF", "value": False}, - ], - "title": "Scheduling", - "type": "scheduling_enabled", - "values": [True, False], - }, - ], - "temperature": 75, - "type": "xxl_zone", - "zone_status": "", - }, - ], - }, - ], - "entry": {"brand": None, "title": "Mock Title"}, - } + assert diag == snapshot From ef6d77586a3ef50e007134e751e0f7683ee117e8 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 15:01:28 -0500 Subject: [PATCH 488/640] Bump python-amcrest to 1.9.8 (#100324) --- homeassistant/components/amcrest/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/amcrest/manifest.json b/homeassistant/components/amcrest/manifest.json index 75d12a3271c0e3..8b8d87092c487c 100644 --- a/homeassistant/components/amcrest/manifest.json +++ b/homeassistant/components/amcrest/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/amcrest", "iot_class": "local_polling", "loggers": ["amcrest"], - "requirements": ["amcrest==1.9.7"] + "requirements": ["amcrest==1.9.8"] } diff --git a/requirements_all.txt b/requirements_all.txt index 58be5cdac44463..86f8c52892a367 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -403,7 +403,7 @@ alpha-vantage==2.3.1 amberelectric==1.0.4 # homeassistant.components.amcrest -amcrest==1.9.7 +amcrest==1.9.8 # homeassistant.components.androidtv androidtv[async]==0.0.70 From 72f5c0741b655f46c83d0bb63d88a304f629a23e Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 13 Sep 2023 22:29:16 +0200 Subject: [PATCH 489/640] Add missing sms coordinator to .coveragerc (#100327) --- .coveragerc | 1 + 1 file changed, 1 insertion(+) diff --git a/.coveragerc b/.coveragerc index 686e3eaaadde62..305d02f2dbd692 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1139,6 +1139,7 @@ omit = homeassistant/components/smarty/* homeassistant/components/sms/__init__.py homeassistant/components/sms/const.py + homeassistant/components/sms/coordinator.py homeassistant/components/sms/gateway.py homeassistant/components/sms/notify.py homeassistant/components/sms/sensor.py From fe5eba9b31bb525f075edf046af8b3542db85e84 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 15:36:07 -0500 Subject: [PATCH 490/640] Use cached_property in device registry (#100309) --- homeassistant/helpers/device_registry.py | 14 +++++--------- tests/syrupy.py | 1 - 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 64d102d020fed4..064579a95d34e2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -12,6 +12,7 @@ import attr from yarl import URL +from homeassistant.backports.functools import cached_property from homeassistant.const import EVENT_HOMEASSISTANT_STARTED, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError @@ -211,7 +212,7 @@ def _validate_configuration_url(value: Any) -> str | None: return str(value) -@attr.s(slots=True, frozen=True) +@attr.s(frozen=True) class DeviceEntry: """Device Registry Entry.""" @@ -234,8 +235,6 @@ class DeviceEntry: # This value is not stored, just used to keep track of events to fire. is_new: bool = attr.ib(default=False) - _json_repr: str | None = attr.ib(cmp=False, default=None, init=False, repr=False) - @property def disabled(self) -> bool: """Return if entry is disabled.""" @@ -262,15 +261,12 @@ def dict_repr(self) -> dict[str, Any]: "via_device_id": self.via_device_id, } - @property + @cached_property def json_repr(self) -> str | None: """Return a cached JSON representation of the entry.""" - if self._json_repr is not None: - return self._json_repr - try: dict_repr = self.dict_repr - object.__setattr__(self, "_json_repr", JSON_DUMP(dict_repr)) + return JSON_DUMP(dict_repr) except (ValueError, TypeError): _LOGGER.error( "Unable to serialize entry %s to JSON. Bad data found at %s", @@ -279,7 +275,7 @@ def json_repr(self) -> str | None: find_paths_unserializable_data(dict_repr, dump=JSON_DUMP) ), ) - return self._json_repr + return None @attr.s(slots=True, frozen=True) diff --git a/tests/syrupy.py b/tests/syrupy.py index 4846e013f5d551..9209654a607988 100644 --- a/tests/syrupy.py +++ b/tests/syrupy.py @@ -158,7 +158,6 @@ def _serializable_device_registry_entry( ) if serialized["via_device_id"] is not None: serialized["via_device_id"] = ANY - serialized.pop("_json_repr") return serialized @classmethod From a02fcbc5c4ed974a5bf4d83bce168ca85e8fb288 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 13 Sep 2023 23:37:48 +0200 Subject: [PATCH 491/640] Update sentry-sdk to 1.31.0 (#100293) --- homeassistant/components/sentry/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sentry/manifest.json b/homeassistant/components/sentry/manifest.json index 149e503d0f8e49..fa1044414bb9b0 100644 --- a/homeassistant/components/sentry/manifest.json +++ b/homeassistant/components/sentry/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/sentry", "integration_type": "service", "iot_class": "cloud_polling", - "requirements": ["sentry-sdk==1.28.1"] + "requirements": ["sentry-sdk==1.31.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 86f8c52892a367..3c453903dac28a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2388,7 +2388,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 82e2d21b9434d5..f13438bf85e81b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1754,7 +1754,7 @@ sensorpro-ble==0.5.3 sensorpush-ble==1.5.5 # homeassistant.components.sentry -sentry-sdk==1.28.1 +sentry-sdk==1.31.0 # homeassistant.components.sfr_box sfrbox-api==0.0.6 From a7c6abfed1db20c2dcc48c2b61f76715a5ac3cf1 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 00:11:27 +0200 Subject: [PATCH 492/640] Use shorthand atts for met_eireann (#100335) --- .../components/met_eireann/weather.py | 44 ++++++------------- 1 file changed, 14 insertions(+), 30 deletions(-) diff --git a/homeassistant/components/met_eireann/weather.py b/homeassistant/components/met_eireann/weather.py index 3a45a74c36b8af..7602dca8343294 100644 --- a/homeassistant/components/met_eireann/weather.py +++ b/homeassistant/components/met_eireann/weather.py @@ -94,24 +94,20 @@ def __init__(self, coordinator, config, hourly): self._attr_unique_id = _calculate_unique_id(config, hourly) self._config = config self._hourly = hourly - - @property - def name(self): - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " Hourly" - - if name is not None: - return f"{name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._hourly + name_appendix = " Hourly" if hourly else "" + if (name := self._config.get(CONF_NAME)) is not None: + self._attr_name = f"{name}{name_appendix}" + else: + self._attr_name = f"{DEFAULT_NAME}{name_appendix}" + self._attr_entity_registry_enabled_default = not hourly + self._attr_device_info = DeviceInfo( + name="Forecast", + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN,)}, + manufacturer="Met Éireann", + model="Forecast", + configuration_url="https://www.met.ie", + ) @property def condition(self): @@ -191,15 +187,3 @@ def _async_forecast_daily(self) -> list[Forecast]: def _async_forecast_hourly(self) -> list[Forecast]: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self): - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, - manufacturer="Met Éireann", - model="Forecast", - configuration_url="https://www.met.ie", - ) From 01410c9fbb912ffdde00798ae337ccf040fbd056 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 00:11:47 +0200 Subject: [PATCH 493/640] Shorthanded attrs for met integration (#100334) --- homeassistant/components/met/weather.py | 74 +++++++++---------------- 1 file changed, 26 insertions(+), 48 deletions(-) diff --git a/homeassistant/components/met/weather.py b/homeassistant/components/met/weather.py index a5a0d34d4ebf6f..a1cc1ade8e14c0 100644 --- a/homeassistant/components/met/weather.py +++ b/homeassistant/components/met/weather.py @@ -2,7 +2,7 @@ from __future__ import annotations from types import MappingProxyType -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, @@ -51,11 +51,16 @@ async def async_setup_entry( coordinator: MetDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] entity_registry = er.async_get(hass) - entities = [ - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, False - ) - ] + name: str | None + 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) + + entities = [MetWeather(coordinator, config_entry.data, False, name, is_metric)] # Add hourly entity to legacy config entries if entity_registry.async_get_entity_id( @@ -63,10 +68,9 @@ async def async_setup_entry( DOMAIN, _calculate_unique_id(config_entry.data, True), ): + name = f"{name} hourly" entities.append( - MetWeather( - coordinator, config_entry.data, hass.config.units is METRIC_SYSTEM, True - ) + MetWeather(coordinator, config_entry.data, True, name, is_metric) ) async_add_entities(entities) @@ -111,8 +115,9 @@ def __init__( self, coordinator: MetDataUpdateCoordinator, config: MappingProxyType[str, Any], - is_metric: bool, hourly: bool, + name: str, + is_metric: bool, ) -> None: """Initialise the platform with a data instance and site.""" super().__init__(coordinator) @@ -120,32 +125,17 @@ def __init__( self._config = config self._is_metric = is_metric self._hourly = hourly - - @property - def track_home(self) -> Any | bool: - """Return if we are tracking home.""" - return self._config.get(CONF_TRACK_HOME, False) - - @property - def name(self) -> str: - """Return the name of the sensor.""" - name = self._config.get(CONF_NAME) - name_appendix = "" - if self._hourly: - name_appendix = " hourly" - - if name is not None: - return f"{name}{name_appendix}" - - if self.track_home: - return f"{self.hass.config.location_name}{name_appendix}" - - return f"{DEFAULT_NAME}{name_appendix}" - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - return not self._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] + manufacturer="Met.no", + model="Forecast", + configuration_url="https://www.met.no/en", + ) + self._attr_track_home = self._config.get(CONF_TRACK_HOME, False) + self._attr_name = name @property def condition(self) -> str | None: @@ -248,15 +238,3 @@ def _async_forecast_daily(self) -> list[Forecast] | None: def _async_forecast_hourly(self) -> list[Forecast] | None: """Return the hourly forecast in native units.""" return self._forecast(True) - - @property - def device_info(self) -> DeviceInfo: - """Device info.""" - return DeviceInfo( - name="Forecast", - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN,)}, # type: ignore[arg-type] - manufacturer="Met.no", - model="Forecast", - configuration_url="https://www.met.no/en", - ) From f0e607869a7dc527da044fe5784a681b05f6addc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:25:33 -0500 Subject: [PATCH 494/640] Use shorthand attributes for supla cover device class (#100337) from #95315 --- homeassistant/components/supla/cover.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/supla/cover.py b/homeassistant/components/supla/cover.py index 53e57fe18540eb..cc3a5a4ed0ca10 100644 --- a/homeassistant/components/supla/cover.py +++ b/homeassistant/components/supla/cover.py @@ -95,6 +95,8 @@ async def async_stop_cover(self, **kwargs: Any) -> None: class SuplaDoorEntity(SuplaEntity, CoverEntity): """Representation of a Supla door.""" + _attr_device_class = CoverDeviceClass.GARAGE + @property def is_closed(self) -> bool | None: """Return if the door is closed or not.""" @@ -120,8 +122,3 @@ async def async_stop_cover(self, **kwargs: Any) -> None: async def async_toggle(self, **kwargs: Any) -> None: """Toggle the door.""" await self.async_action("OPEN_CLOSE") - - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of this device, from component DEVICE_CLASSES.""" - return CoverDeviceClass.GARAGE From fe8156f01344eb50a7983b35234b6228af8c2cbc Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:25:52 -0500 Subject: [PATCH 495/640] Bump protobuf to 4.24.3 (#100329) changelog: https://github.com/protocolbuffers/protobuf/compare/v24.0...v24.3 --- 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 cf815b43b91941..21616a68c32009 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -148,7 +148,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7d587d761ecfcd..c2bbfd4ffe3ad6 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -149,7 +149,7 @@ # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.24.0 +protobuf==4.24.3 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From 3be4edd647fcf0a21aeccc2faca85c22b7dc2f41 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:26:22 -0500 Subject: [PATCH 496/640] Use shorthand attributes in saj (#100317) supports #95315 --- homeassistant/components/saj/sensor.py | 52 +++++++++++--------------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/homeassistant/components/saj/sensor.py b/homeassistant/components/saj/sensor.py index 12a5ae99570e6c..866279af973811 100644 --- a/homeassistant/components/saj/sensor.py +++ b/homeassistant/components/saj/sensor.py @@ -181,7 +181,12 @@ class SAJsensor(SensorEntity): _attr_should_poll = False - def __init__(self, serialnumber, pysaj_sensor, inverter_name=None): + def __init__( + self, + serialnumber: str | None, + pysaj_sensor: pysaj.Sensor, + inverter_name: str | None = None, + ) -> None: """Initialize the SAJ sensor.""" self._sensor = pysaj_sensor self._inverter_name = inverter_name @@ -193,38 +198,28 @@ def __init__(self, serialnumber, pysaj_sensor, inverter_name=None): if pysaj_sensor.name == "total_yield": self._attr_state_class = SensorStateClass.TOTAL_INCREASING - @property - def name(self) -> str: - """Return the name of the sensor.""" + self._attr_unique_id = f"{serialnumber}_{pysaj_sensor.name}" + native_uom = SAJ_UNIT_MAPPINGS[pysaj_sensor.unit] + self._attr_native_unit_of_measurement = native_uom if self._inverter_name: - return f"saj_{self._inverter_name}_{self._sensor.name}" - - return f"saj_{self._sensor.name}" + self._attr_name = f"saj_{self._inverter_name}_{pysaj_sensor.name}" + else: + self._attr_name = f"saj_{pysaj_sensor.name}" + if native_uom == UnitOfPower.WATT: + self._attr_device_class = SensorDeviceClass.POWER + if native_uom == UnitOfEnergy.KILO_WATT_HOUR: + self._attr_device_class = SensorDeviceClass.ENERGY + if native_uom in ( + UnitOfTemperature.CELSIUS, + UnitOfTemperature.FAHRENHEIT, + ): + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): """Return the state of the sensor.""" return self._state - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit the value is expressed in.""" - return SAJ_UNIT_MAPPINGS[self._sensor.unit] - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class the sensor belongs to.""" - if self.native_unit_of_measurement == UnitOfPower.WATT: - return SensorDeviceClass.POWER - if self.native_unit_of_measurement == UnitOfEnergy.KILO_WATT_HOUR: - return SensorDeviceClass.ENERGY - if self.native_unit_of_measurement in ( - UnitOfTemperature.CELSIUS, - UnitOfTemperature.FAHRENHEIT, - ): - return SensorDeviceClass.TEMPERATURE - return None - @property def per_day_basis(self) -> bool: """Return if the sensors value is on daily basis or not.""" @@ -255,8 +250,3 @@ def async_update_values(self, unknown_state=False): if update: self.async_write_ha_state() - - @property - def unique_id(self) -> str: - """Return a unique identifier for this sensor.""" - return f"{self._serialnumber}_{self._sensor.name}" From 3cc9410a62f2b7eb914a6556dfc548b86c19f203 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:26:55 -0500 Subject: [PATCH 497/640] Bump grpcio to 1.58.0 (#100314) * Bump grpcio to 1.58.0 attempt to fix nightly https://github.com/home-assistant/core/actions/runs/6167125867/job/16737677629 ``` ``` * forgot the script as well --- homeassistant/package_constraints.txt | 6 +++--- script/gen_requirements_all.py | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 21616a68c32009..2f26e5a6c33c6c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -71,9 +71,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.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.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 c2bbfd4ffe3ad6..8780b9d07438ed 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -72,9 +72,9 @@ # 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.51.1 -grpcio-status==1.51.1 -grpcio-reflection==1.51.1 +grpcio==1.58.0 +grpcio-status==1.58.0 +grpcio-reflection==1.58.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 547f32818c8cfe13bbc69a5779b288e6b93048b9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 13 Sep 2023 19:33:25 -0500 Subject: [PATCH 498/640] Make core States use cached_property (#100312) Need to validate this is worth removing __slots__ --- homeassistant/components/api/__init__.py | 6 ++-- .../components/websocket_api/commands.py | 8 ++--- .../components/websocket_api/messages.py | 2 +- homeassistant/core.py | 30 ++++--------------- homeassistant/helpers/template.py | 2 -- tests/test_core.py | 20 ++++++------- 6 files changed, 24 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index a1a2d1107b9c97..0cade0f81ca654 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -202,11 +202,11 @@ def get(self, request: web.Request) -> web.Response: user: User = request["hass_user"] hass: HomeAssistant = request.app["hass"] if user.is_admin: - states = (state.as_dict_json() for state in hass.states.async_all()) + states = (state.as_dict_json for state in hass.states.async_all()) else: entity_perm = user.permissions.check_entity states = ( - state.as_dict_json() + state.as_dict_json for state in hass.states.async_all() if entity_perm(state.entity_id, "read") ) @@ -233,7 +233,7 @@ def get(self, request: web.Request, entity_id: str) -> web.Response: if state := hass.states.get(entity_id): return web.Response( - body=state.as_dict_json(), + body=state.as_dict_json, content_type=CONTENT_TYPE_JSON, ) return self.json_message("Entity not found.", HTTPStatus.NOT_FOUND) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index bd7d3b530cdf3e..cef9e7bb706c35 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -270,7 +270,7 @@ def handle_get_states( states = _async_get_allowed_states(hass, connection) try: - serialized_states = [state.as_dict_json() for state in states] + serialized_states = [state.as_dict_json for state in states] except (ValueError, TypeError): pass else: @@ -281,7 +281,7 @@ def handle_get_states( serialized_states = [] for state in states: try: - serialized_states.append(state.as_dict_json()) + serialized_states.append(state.as_dict_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", @@ -358,7 +358,7 @@ def handle_subscribe_entities( # to succeed for the UI to show. try: serialized_states = [ - state.as_compressed_state_json() + state.as_compressed_state_json for state in states if not entity_ids or state.entity_id in entity_ids ] @@ -371,7 +371,7 @@ def handle_subscribe_entities( serialized_states = [] for state in states: try: - serialized_states.append(state.as_compressed_state_json()) + serialized_states.append(state.as_compressed_state_json) except (ValueError, TypeError): connection.logger.error( "Unable to serialize to JSON. Bad data found at %s", diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 1114eec4fac5c5..6e88c36c32818d 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -141,7 +141,7 @@ def _state_diff_event(event: Event) -> dict: if (event_old_state := event.data["old_state"]) is None: return { ENTITY_EVENT_ADD: { - event_new_state.entity_id: event_new_state.as_compressed_state() + event_new_state.entity_id: event_new_state.as_compressed_state } } if TYPE_CHECKING: diff --git a/homeassistant/core.py b/homeassistant/core.py index cbfc8097c7fa26..a43fa1997c6812 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -35,6 +35,7 @@ import yarl from . import block_async_io, util +from .backports.functools import cached_property from .const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, @@ -1239,20 +1240,6 @@ class State: object_id: Object id of this state. """ - __slots__ = ( - "entity_id", - "state", - "attributes", - "last_changed", - "last_updated", - "context", - "domain", - "object_id", - "_as_dict", - "_as_dict_json", - "_as_compressed_state_json", - ) - def __init__( self, entity_id: str, @@ -1282,8 +1269,6 @@ def __init__( self.context = context or Context() self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None - self._as_dict_json: str | None = None - self._as_compressed_state_json: str | None = None @property def name(self) -> str: @@ -1318,12 +1303,12 @@ def as_dict(self) -> ReadOnlyDict[str, Collection[Any]]: ) return self._as_dict + @cached_property def as_dict_json(self) -> str: """Return a JSON string of the State.""" - if not self._as_dict_json: - self._as_dict_json = json_dumps(self.as_dict()) - return self._as_dict_json + return json_dumps(self.as_dict()) + @cached_property def as_compressed_state(self) -> dict[str, Any]: """Build a compressed dict of a state for adds. @@ -1348,6 +1333,7 @@ def as_compressed_state(self) -> dict[str, Any]: ) return compressed_state + @cached_property def as_compressed_state_json(self) -> str: """Build a compressed JSON key value pair of a state for adds. @@ -1355,11 +1341,7 @@ def as_compressed_state_json(self) -> str: It is used for sending multiple states in a single message. """ - if not self._as_compressed_state_json: - self._as_compressed_state_json = json_dumps( - {self.entity_id: self.as_compressed_state()} - )[1:-1] - return self._as_compressed_state_json + return json_dumps({self.entity_id: self.as_compressed_state})[1:-1] @classmethod def from_dict(cls, json_dict: dict[str, Any]) -> Self | None: diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 070e5b6d9ad41e..b0754c13c7c503 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -929,8 +929,6 @@ def __repr__(self) -> str: class TemplateStateBase(State): """Class to represent a state object in a template.""" - __slots__ = ("_hass", "_collect", "_entity_id", "__dict__") - _state: State __setitem__ = _readonly diff --git a/tests/test_core.py b/tests/test_core.py index c5ce9eb0881513..7cafadb638ce9e 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -671,11 +671,11 @@ def test_state_as_dict_json() -> None: '"last_changed":"1984-12-08T12:00:00","last_updated":"1984-12-08T12:00:00",' '"context":{"id":"01H0D6K3RFJAYAV2093ZW30PCW","parent_id":null,"user_id":null}}' ) - as_dict_json_1 = state.as_dict_json() + as_dict_json_1 = state.as_dict_json assert as_dict_json_1 == expected # 2nd time to verify cache - assert state.as_dict_json() == expected - assert state.as_dict_json() is as_dict_json_1 + assert state.as_dict_json == expected + assert state.as_dict_json is as_dict_json_1 def test_state_as_compressed_state() -> None: @@ -694,12 +694,12 @@ def test_state_as_compressed_state() -> None: "lc": last_time.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_unique_last_updated() -> None: @@ -720,12 +720,12 @@ def test_state_as_compressed_state_unique_last_updated() -> None: "lu": last_updated.timestamp(), "s": "on", } - as_compressed_state = state.as_compressed_state() + as_compressed_state = state.as_compressed_state # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state() == expected + assert state.as_compressed_state == expected def test_state_as_compressed_state_json() -> None: @@ -740,13 +740,13 @@ def test_state_as_compressed_state_json() -> None: context=ha.Context(id="01H0D6H5K3SZJ3XGDHED1TJ79N"), ) expected = '"happy.happy":{"s":"on","a":{"pig":"dog"},"c":"01H0D6H5K3SZJ3XGDHED1TJ79N","lc":471355200.0}' - as_compressed_state = state.as_compressed_state_json() + as_compressed_state = state.as_compressed_state_json # We are not too concerned about these being ReadOnlyDict # since we don't expect them to be called by external callers assert as_compressed_state == expected # 2nd time to verify cache - assert state.as_compressed_state_json() == expected - assert state.as_compressed_state_json() is as_compressed_state + assert state.as_compressed_state_json == expected + assert state.as_compressed_state_json is as_compressed_state async def test_eventbus_add_remove_listener(hass: HomeAssistant) -> None: From c265d3f3ccf464c32154219369453974f13b5cd3 Mon Sep 17 00:00:00 2001 From: mkmer Date: Thu, 14 Sep 2023 00:22:28 -0400 Subject: [PATCH 499/640] Late review for honeywell (#100299) * Late review for honeywell * Actually test same id different domain * Update homeassistant/components/honeywell/climate.py Co-authored-by: Martin Hjelmare * Update climate.py * Refactor dont_remove --------- Co-authored-by: Martin Hjelmare --- homeassistant/components/honeywell/climate.py | 11 ++++++++--- tests/components/honeywell/test_init.py | 18 ++++++++++++++++-- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/honeywell/climate.py b/homeassistant/components/honeywell/climate.py index c285ab83bd1111..63d05135d5df9c 100644 --- a/homeassistant/components/honeywell/climate.py +++ b/homeassistant/components/honeywell/climate.py @@ -111,18 +111,23 @@ def remove_stale_devices( device_entries = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - all_device_ids: list = [] + all_device_ids: set = set() for device in devices.values(): - all_device_ids.append(device.deviceid) + all_device_ids.add(device.deviceid) for device_entry in device_entries: device_id: str | None = None + remove = True for identifier in device_entry.identifiers: + if identifier[0] != DOMAIN: + remove = False + continue + device_id = identifier[1] break - if device_id is None or device_id not in all_device_ids: + if remove and (device_id is None or device_id not in all_device_ids): # If device_id is None an invalid device entry was found for this config entry. # If the device_id is not in existing device ids it's a stale device entry. # Remove config entry from this device entry in either case. diff --git a/tests/components/honeywell/test_init.py b/tests/components/honeywell/test_init.py index e5afe311295f1f..73dda8ed223703 100644 --- a/tests/components/honeywell/test_init.py +++ b/tests/components/honeywell/test_init.py @@ -130,7 +130,15 @@ async def test_remove_stale_device( ) -> None: """Test that the stale device is removed.""" location.devices_by_id[another_device.deviceid] = another_device + config_entry.add_to_hass(hass) + + device_registry = dr.async_get(hass) + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={("OtherDomain", 7654321)}, + ) + await hass.config_entries.async_setup(config_entry.entry_id) await hass.async_block_till_done() assert config_entry.state is ConfigEntryState.LOADED @@ -142,9 +150,12 @@ async def test_remove_stale_device( device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 2 + assert len(device_entry) == 3 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) assert any((DOMAIN, 7654321) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) assert await config_entry.async_unload(hass) await hass.async_block_till_done() @@ -162,5 +173,8 @@ async def test_remove_stale_device( device_entry = dr.async_entries_for_config_entry( device_registry, config_entry.entry_id ) - assert len(device_entry) == 1 + assert len(device_entry) == 2 assert any((DOMAIN, 1234567) in device.identifiers for device in device_entry) + assert any( + ("OtherDomain", 7654321) in device.identifiers for device in device_entry + ) From 58bb624b24d8a5ad030400155059f0a262a7f2a1 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 00:54:17 -0500 Subject: [PATCH 500/640] Bump zeroconf to 0.111.0 (#100340) --- 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 1e2205a1c1b833..34cf72f180d4aa 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.108.0"] + "requirements": ["zeroconf==0.111.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 2f26e5a6c33c6c..bd8984afe00260 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.108.0 +zeroconf==0.111.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 3c453903dac28a..6cd211a7ca146a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.108.0 +zeroconf==0.111.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f13438bf85e81b..0cb9ca0c2159e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2045,7 +2045,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.108.0 +zeroconf==0.111.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From 182976f5d3000e14a205f9fb0ec4d2d36cf498ac Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 02:03:39 -0500 Subject: [PATCH 501/640] Use more shorthand attributes in threshold binary_sensor (#100343) --- .../components/threshold/binary_sensor.py | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/threshold/binary_sensor.py b/homeassistant/components/threshold/binary_sensor.py index 3e702f0ebdbb30..6382c79b9ce877 100644 --- a/homeassistant/components/threshold/binary_sensor.py +++ b/homeassistant/components/threshold/binary_sensor.py @@ -183,14 +183,14 @@ def __init__( self._attr_unique_id = unique_id self._attr_device_info = device_info self._entity_id = entity_id - self._name = name + self._attr_name = name if lower is not None: self._threshold_lower = lower if upper is not None: self._threshold_upper = upper self.threshold_type = _threshold_type(lower, upper) self._hysteresis: float = hysteresis - self._device_class = device_class + self._attr_device_class = device_class self._state_position = POSITION_UNKNOWN self._state: bool | None = None self.sensor_value: float | None = None @@ -227,21 +227,11 @@ def async_threshold_sensor_state_listener( ) _update_sensor_state() - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name - @property def is_on(self) -> bool | None: """Return true if sensor is on.""" return self._state - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the sensor class of the sensor.""" - return self._device_class - @property def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" From 6692a37f0d2314f36edbfb93b2409d3648445647 Mon Sep 17 00:00:00 2001 From: Marty Sun Date: Thu, 14 Sep 2023 15:04:12 +0800 Subject: [PATCH 502/640] Add missing __init__.py file in yardian test folder (#100345) --- CODEOWNERS | 1 + requirements_test_all.txt | 3 +++ tests/components/yardian/__init__.py | 1 + 3 files changed, 5 insertions(+) create mode 100644 tests/components/yardian/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 3aefaabb50b676..7463731e57a554 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1458,6 +1458,7 @@ build.json @home-assistant/supervisor /homeassistant/components/yandex_transport/ @rishatik92 @devbis /tests/components/yandex_transport/ @rishatik92 @devbis /homeassistant/components/yardian/ @h3l1o5 +/tests/components/yardian/ @h3l1o5 /homeassistant/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /tests/components/yeelight/ @zewelor @shenxn @starkillerOG @alexyao2015 /homeassistant/components/yeelightsunflower/ @lindsaymarkward diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0cb9ca0c2159e1..02e2be740820b1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1666,6 +1666,9 @@ pywizlight==0.5.14 # homeassistant.components.ws66i pyws66i==1.1 +# homeassistant.components.yardian +pyyardian==1.1.0 + # homeassistant.components.zerproc pyzerproc==0.4.8 diff --git a/tests/components/yardian/__init__.py b/tests/components/yardian/__init__.py new file mode 100644 index 00000000000000..47f8cbc509e099 --- /dev/null +++ b/tests/components/yardian/__init__.py @@ -0,0 +1 @@ +"""Tests for the yardian integration.""" From 923d9452674f58c9a54b95c8a23f8f05bf702754 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 09:25:21 +0200 Subject: [PATCH 503/640] Use shorthand attributes in Smappee (#99837) --- .../components/smappee/binary_sensor.py | 136 ++++++------------ homeassistant/components/smappee/sensor.py | 18 +-- homeassistant/components/smappee/switch.py | 22 ++- 3 files changed, 61 insertions(+), 115 deletions(-) diff --git a/homeassistant/components/smappee/binary_sensor.py b/homeassistant/components/smappee/binary_sensor.py index 71bbaa472aef5b..ed09b51ff25376 100644 --- a/homeassistant/components/smappee/binary_sensor.py +++ b/homeassistant/components/smappee/binary_sensor.py @@ -15,6 +15,23 @@ BINARY_SENSOR_PREFIX = "Appliance" PRESENCE_PREFIX = "Presence" +ICON_MAPPING = { + "Car Charger": "mdi:car", + "Coffeemaker": "mdi:coffee", + "Clothes Dryer": "mdi:tumble-dryer", + "Clothes Iron": "mdi:hanger", + "Dishwasher": "mdi:dishwasher", + "Lights": "mdi:lightbulb", + "Fan": "mdi:fan", + "Freezer": "mdi:fridge", + "Microwave": "mdi:microwave", + "Oven": "mdi:stove", + "Refrigerator": "mdi:fridge", + "Stove": "mdi:stove", + "Washing Machine": "mdi:washing-machine", + "Water Pump": "mdi:water-pump", +} + async def async_setup_entry( hass: HomeAssistant, @@ -48,54 +65,33 @@ async def async_setup_entry( class SmappeePresence(BinarySensorEntity): """Implementation of a Smappee presence binary sensor.""" + _attr_device_class = BinarySensorDeviceClass.PRESENCE + def __init__(self, smappee_base, service_location): """Initialize the Smappee sensor.""" self._smappee_base = smappee_base self._service_location = service_location - self._state = self._service_location.is_present - - @property - def name(self): - """Return the name of the binary sensor.""" - return f"{self._service_location.service_location_name} - {PRESENCE_PREFIX}" - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def device_class(self): - """Return the class of this device, from component DEVICE_CLASSES.""" - return BinarySensorDeviceClass.PRESENCE - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" + self._attr_name = ( + f"{service_location.service_location_name} - {PRESENCE_PREFIX}" + ) + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" f"{BinarySensorDeviceClass.PRESENCE}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() - self._state = self._service_location.is_present + self._attr_is_on = self._service_location.is_present class SmappeeAppliance(BinarySensorEntity): @@ -113,70 +109,28 @@ def __init__( self._smappee_base = smappee_base self._service_location = service_location self._appliance_id = appliance_id - self._appliance_name = appliance_name - self._appliance_type = appliance_type - self._state = False - - @property - def name(self): - """Return the name of the sensor.""" - return ( - f"{self._service_location.service_location_name} - " + self._attr_name = ( + f"{service_location.service_location_name} - " f"{BINARY_SENSOR_PREFIX} - " - f"{self._appliance_name if self._appliance_name != '' else self._appliance_type}" + f"{appliance_name if appliance_name != '' else appliance_type}" ) - - @property - def is_on(self): - """Return if the binary sensor is turned on.""" - return self._state - - @property - def icon(self): - """Icon to use in the frontend.""" - icon_mapping = { - "Car Charger": "mdi:car", - "Coffeemaker": "mdi:coffee", - "Clothes Dryer": "mdi:tumble-dryer", - "Clothes Iron": "mdi:hanger", - "Dishwasher": "mdi:dishwasher", - "Lights": "mdi:lightbulb", - "Fan": "mdi:fan", - "Freezer": "mdi:fridge", - "Microwave": "mdi:microwave", - "Oven": "mdi:stove", - "Refrigerator": "mdi:fridge", - "Stove": "mdi:stove", - "Washing Machine": "mdi:washing-machine", - "Water Pump": "mdi:water-pump", - } - return icon_mapping.get(self._appliance_type) - - @property - def unique_id( - self, - ): - """Return the unique ID for this binary sensor.""" - return ( - f"{self._service_location.device_serial_number}-" - f"{self._service_location.service_location_id}-" - f"appliance-{self._appliance_id}" + self._attr_unique_id = ( + f"{service_location.device_serial_number}-" + f"{service_location.service_location_id}-" + f"appliance-{appliance_id}" ) - - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this binary sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, ) + self._attr_icon = ICON_MAPPING.get(appliance_type) async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() appliance = self._service_location.appliances.get(self._appliance_id) - self._state = bool(appliance.state) + self._attr_is_on = bool(appliance.state) diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 4228f57ea4684b..82bc60936b3394 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -341,6 +341,13 @@ def __init__( self.entity_description = description self._smappee_base = smappee_base self._service_location = service_location + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -372,17 +379,6 @@ def unique_id(self): f"{sensor_key}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this sensor.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() diff --git a/homeassistant/components/smappee/switch.py b/homeassistant/components/smappee/switch.py index 1928e717f22a86..238e41af8ffd0a 100644 --- a/homeassistant/components/smappee/switch.py +++ b/homeassistant/components/smappee/switch.py @@ -74,10 +74,17 @@ def __init__( self._actuator_type = actuator_type self._actuator_serialnumber = actuator_serialnumber self._actuator_state_option = actuator_state_option - self._state = self._service_location.actuators.get(actuator_id).state - self._connection_state = self._service_location.actuators.get( + self._state = service_location.actuators.get(actuator_id).state + self._connection_state = service_location.actuators.get( actuator_id ).connection_state + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, service_location.device_serial_number)}, + manufacturer="Smappee", + model=service_location.device_model, + name=service_location.service_location_name, + sw_version=service_location.firmware_version, + ) @property def name(self): @@ -153,17 +160,6 @@ def unique_id( f"{self._actuator_id}" ) - @property - def device_info(self) -> DeviceInfo: - """Return the device info for this switch.""" - return DeviceInfo( - identifiers={(DOMAIN, self._service_location.device_serial_number)}, - manufacturer="Smappee", - model=self._service_location.device_model, - name=self._service_location.service_location_name, - sw_version=self._service_location.firmware_version, - ) - async def async_update(self) -> None: """Get the latest data from Smappee and update the state.""" await self._smappee_base.async_update() From 840d881c25de33f5d5750cb613a81b6101ae205a Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 14 Sep 2023 09:27:16 +0200 Subject: [PATCH 504/640] Add icon to GPSD (#100347) --- homeassistant/components/gpsd/sensor.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/gpsd/sensor.py b/homeassistant/components/gpsd/sensor.py index 3e356f1509c8a4..64b86434c3c5d0 100644 --- a/homeassistant/components/gpsd/sensor.py +++ b/homeassistant/components/gpsd/sensor.py @@ -121,3 +121,12 @@ def extra_state_attributes(self) -> dict[str, Any]: ATTR_CLIMB: self.agps_thread.data_stream.climb, ATTR_MODE: self.agps_thread.data_stream.mode, } + + @property + def icon(self) -> str: + """Return the icon of the sensor.""" + mode = self.agps_thread.data_stream.mode + + if isinstance(mode, int) and mode >= 2: + return "mdi:crosshairs-gps" + return "mdi:crosshairs" From 4a48a92cba5cb3fb6e13eabc6014f85f5407bc92 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 10:06:43 +0200 Subject: [PATCH 505/640] Use f-string instead of concatenation in Velux (#100353) --- homeassistant/components/velux/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index ef5525731153e6..b43ee39ed4e4d8 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -94,7 +94,7 @@ def __init__(self, node: OpeningDevice) -> None: """Initialize the Velux device.""" self.node = node self._attr_unique_id = node.serial_number - self._attr_name = node.name if node.name else "#" + str(node.node_id) + self._attr_name = node.name if node.name else f"#{node.node_id}" @callback def async_register_callbacks(self): From 85fafcad1165b7a0c6b91c4afec10a24e8cfa440 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Sep 2023 10:07:09 +0200 Subject: [PATCH 506/640] Update awesomeversion to 23.8.0 (#100349) --- 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 bd8984afe00260..8beeae2f96076a 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -6,7 +6,7 @@ async-timeout==4.0.3 async-upnp-client==0.35.1 atomicwrites-homeassistant==1.4.1 attrs==23.1.0 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 bleak-retry-connector==3.1.3 bleak==0.21.1 diff --git a/pyproject.toml b/pyproject.toml index 7bc3edc9bf0eb7..bfc3472651c7db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "async-timeout==4.0.3", "attrs==23.1.0", "atomicwrites-homeassistant==1.4.1", - "awesomeversion==22.9.0", + "awesomeversion==23.8.0", "bcrypt==4.0.1", "certifi>=2021.5.30", "ciso8601==2.3.0", diff --git a/requirements.txt b/requirements.txt index 28e853f4fe1cf5..2f6024a2e6a5c5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ astral==2.2 async-timeout==4.0.3 attrs==23.1.0 atomicwrites-homeassistant==1.4.1 -awesomeversion==22.9.0 +awesomeversion==23.8.0 bcrypt==4.0.1 certifi>=2021.5.30 ciso8601==2.3.0 From 4f58df1929551dc9516863f3123fc4f241c5ff26 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 14 Sep 2023 10:09:55 +0200 Subject: [PATCH 507/640] Drop useless passing of update_method to DataUpdateCoordinator (#100355) --- homeassistant/components/goodwe/coordinator.py | 1 - homeassistant/components/rainbird/coordinator.py | 1 - homeassistant/components/vizio/__init__.py | 1 - homeassistant/components/yardian/coordinator.py | 1 - 4 files changed, 4 deletions(-) diff --git a/homeassistant/components/goodwe/coordinator.py b/homeassistant/components/goodwe/coordinator.py index 0ae064e0e97c8d..ac91fba787dbc5 100644 --- a/homeassistant/components/goodwe/coordinator.py +++ b/homeassistant/components/goodwe/coordinator.py @@ -30,7 +30,6 @@ def __init__( _LOGGER, name=entry.title, update_interval=SCAN_INTERVAL, - update_method=self._async_update_data, ) self.inverter: Inverter = inverter self._last_data: dict[str, Any] = {} diff --git a/homeassistant/components/rainbird/coordinator.py b/homeassistant/components/rainbird/coordinator.py index d81b942d669d67..cac86d8c928f1e 100644 --- a/homeassistant/components/rainbird/coordinator.py +++ b/homeassistant/components/rainbird/coordinator.py @@ -54,7 +54,6 @@ def __init__( hass, _LOGGER, name=name, - update_method=self._async_update_data, update_interval=UPDATE_INTERVAL, ) self._controller = controller diff --git a/homeassistant/components/vizio/__init__.py b/homeassistant/components/vizio/__init__.py index d694f4b93f884b..0f5b3bc967cb7f 100644 --- a/homeassistant/components/vizio/__init__.py +++ b/homeassistant/components/vizio/__init__.py @@ -107,7 +107,6 @@ def __init__(self, hass: HomeAssistant, store: Store) -> None: _LOGGER, name=DOMAIN, update_interval=timedelta(days=1), - update_method=self._async_update_data, ) self.fail_count = 0 self.fail_threshold = 10 diff --git a/homeassistant/components/yardian/coordinator.py b/homeassistant/components/yardian/coordinator.py index 526ee3c42ab26f..e7102f9c74bd5a 100644 --- a/homeassistant/components/yardian/coordinator.py +++ b/homeassistant/components/yardian/coordinator.py @@ -39,7 +39,6 @@ def __init__( hass, _LOGGER, name=entry.title, - update_method=self._async_update_data, update_interval=SCAN_INTERVAL, always_update=False, ) From b0f32a35479c312ed09274917d5b1212d83e8858 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Thu, 14 Sep 2023 10:10:31 +0200 Subject: [PATCH 508/640] Update apprise to 1.5.0 (#100351) --- 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 04dcef052025aa..e67192040a6b34 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.4.5"] + "requirements": ["apprise==1.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 6cd211a7ca146a..c0c3c38e9300e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -427,7 +427,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.2 # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 02e2be740820b1..ab581701d119b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -393,7 +393,7 @@ apcaccess==0.0.13 apple_weatherkit==1.0.2 # homeassistant.components.apprise -apprise==1.4.5 +apprise==1.5.0 # homeassistant.components.aprs aprslib==0.7.0 From b84076d3d6393f64ca092160aca6a42c2142ae8f Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 14 Sep 2023 10:28:45 +0200 Subject: [PATCH 509/640] Fix timeout issue in devolo_home_network (#100350) --- homeassistant/components/devolo_home_network/__init__.py | 2 +- homeassistant/components/devolo_home_network/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index f54fddc9a86a4e..627a121dcb40f3 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -74,7 +74,7 @@ async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): + async with asyncio.timeout(30): return await device.device.async_check_firmware_available() except DeviceUnavailable as err: raise UpdateFailed(err) from err diff --git a/homeassistant/components/devolo_home_network/manifest.json b/homeassistant/components/devolo_home_network/manifest.json index a047437e98028c..27fd08898c06e6 100644 --- a/homeassistant/components/devolo_home_network/manifest.json +++ b/homeassistant/components/devolo_home_network/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["devolo_plc_api"], "quality_scale": "platinum", - "requirements": ["devolo-plc-api==1.4.0"], + "requirements": ["devolo-plc-api==1.4.1"], "zeroconf": [ { "type": "_dvl-deviceapi._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index c0c3c38e9300e3..133d43bc2f0142 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -675,7 +675,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ab581701d119b2..9b2912119aabe8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -552,7 +552,7 @@ denonavr==0.11.3 devolo-home-control-api==0.18.2 # homeassistant.components.devolo_home_network -devolo-plc-api==1.4.0 +devolo-plc-api==1.4.1 # homeassistant.components.directv directv==0.4.0 From 98c9edc00c985a9328e5f9596462be839a5644ac Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 12:06:40 +0200 Subject: [PATCH 510/640] Netgear cleanup (#99505) Co-authored-by: Robert Resch Co-authored-by: Joost Lekkerkerker --- .coveragerc | 1 + homeassistant/components/netgear/__init__.py | 19 +-- homeassistant/components/netgear/button.py | 7 +- .../components/netgear/device_tracker.py | 8 +- homeassistant/components/netgear/entity.py | 107 +++++++++++++ homeassistant/components/netgear/router.py | 143 +----------------- homeassistant/components/netgear/sensor.py | 13 +- homeassistant/components/netgear/switch.py | 10 +- homeassistant/components/netgear/update.py | 6 +- 9 files changed, 132 insertions(+), 182 deletions(-) create mode 100644 homeassistant/components/netgear/entity.py diff --git a/.coveragerc b/.coveragerc index 305d02f2dbd692..4835ec5a05bbc7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -793,6 +793,7 @@ omit = homeassistant/components/netgear/__init__.py homeassistant/components/netgear/button.py homeassistant/components/netgear/device_tracker.py + homeassistant/components/netgear/entity.py homeassistant/components/netgear/router.py homeassistant/components/netgear/sensor.py homeassistant/components/netgear/switch.py diff --git a/homeassistant/components/netgear/__init__.py b/homeassistant/components/netgear/__init__.py index 522b60749d004a..b21286ff05be43 100644 --- a/homeassistant/components/netgear/__init__.py +++ b/homeassistant/components/netgear/__init__.py @@ -6,7 +6,7 @@ from typing import Any from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL +from homeassistant.const import CONF_PORT, CONF_SSL from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers import device_registry as dr, entity_registry as er @@ -62,23 +62,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: entry.async_on_unload(entry.add_update_listener(update_listener)) - configuration_url = None - if host := entry.data[CONF_HOST]: - configuration_url = f"http://{host}/" - - assert entry.unique_id - device_registry = dr.async_get(hass) - device_registry.async_get_or_create( - config_entry_id=entry.entry_id, - identifiers={(DOMAIN, entry.unique_id)}, - manufacturer="Netgear", - name=router.device_name, - model=router.model, - sw_version=router.firmware_version, - hw_version=router.hardware_version, - configuration_url=configuration_url, - ) - async def async_update_devices() -> bool: """Fetch data from the router.""" if router.track_devices: diff --git a/homeassistant/components/netgear/button.py b/homeassistant/components/netgear/button.py index e45e0582d69520..f3283f8d7b53f4 100644 --- a/homeassistant/components/netgear/button.py +++ b/homeassistant/components/netgear/button.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter @dataclass @@ -35,7 +36,6 @@ class NetgearButtonEntityDescription( BUTTONS = [ NetgearButtonEntityDescription( key="reboot", - name="Reboot", device_class=ButtonDeviceClass.RESTART, entity_category=EntityCategory.CONFIG, action=lambda router: router.async_reboot, @@ -69,8 +69,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" async def async_press(self) -> None: """Triggers the button press service.""" diff --git a/homeassistant/components/netgear/device_tracker.py b/homeassistant/components/netgear/device_tracker.py index ffb33d5ebeb0fd..38ad024a2c4090 100644 --- a/homeassistant/components/netgear/device_tracker.py +++ b/homeassistant/components/netgear/device_tracker.py @@ -10,7 +10,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DEVICE_ICONS, DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearBaseEntity, NetgearRouter +from .entity import NetgearDeviceEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -46,9 +47,11 @@ def new_device_callback() -> None: new_device_callback() -class NetgearScannerEntity(NetgearBaseEntity, ScannerEntity): +class NetgearScannerEntity(NetgearDeviceEntity, ScannerEntity): """Representation of a device connected to a Netgear router.""" + _attr_has_entity_name = False + def __init__( self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict ) -> None: @@ -56,6 +59,7 @@ def __init__( super().__init__(coordinator, router, device) self._hostname = self.get_hostname() self._icon = DEVICE_ICONS.get(device["device_type"], "mdi:help-network") + self._attr_name = self._device_name def get_hostname(self) -> str | None: """Return the hostname of the given device or None if we don't know.""" diff --git a/homeassistant/components/netgear/entity.py b/homeassistant/components/netgear/entity.py new file mode 100644 index 00000000000000..45418681db0cb8 --- /dev/null +++ b/homeassistant/components/netgear/entity.py @@ -0,0 +1,107 @@ +"""Represent the Netgear router and its devices.""" +from __future__ import annotations + +from abc import abstractmethod + +from homeassistant.const import CONF_HOST +from homeassistant.core import callback +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import DOMAIN +from .router import NetgearRouter + + +class NetgearDeviceEntity(CoordinatorEntity): + """Base class for a device connected to a Netgear router.""" + + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict + ) -> None: + """Initialize a Netgear device.""" + super().__init__(coordinator) + self._router = router + self._device = device + self._mac = device["mac"] + self._device_name = self.get_device_name() + self._active = device["active"] + self._attr_unique_id = self._mac + self._attr_device_info = DeviceInfo( + connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, + default_name=self._device_name, + default_model=device["device_model"], + via_device=(DOMAIN, router.unique_id), + ) + + def get_device_name(self): + """Return the name of the given device or the MAC if we don't know.""" + name = self._device["name"] + if not name or name == "--": + name = self._mac + + return name + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() + + +class NetgearRouterEntity(Entity): + """Base class for a Netgear router entity without coordinator.""" + + _attr_has_entity_name = True + + def __init__(self, router: NetgearRouter) -> None: + """Initialize a Netgear device.""" + self._router = router + + configuration_url = None + if host := router.entry.data[CONF_HOST]: + configuration_url = f"http://{host}/" + + self._attr_unique_id = router.serial_number + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, router.unique_id)}, + manufacturer="Netgear", + name=router.device_name, + model=router.model, + sw_version=router.firmware_version, + hw_version=router.hardware_version, + configuration_url=configuration_url, + ) + + +class NetgearRouterCoordinatorEntity(NetgearRouterEntity, CoordinatorEntity): + """Base class for a Netgear router entity.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, router: NetgearRouter + ) -> None: + """Initialize a Netgear device.""" + CoordinatorEntity.__init__(self, coordinator) + NetgearRouterEntity.__init__(self, router) + + @abstractmethod + @callback + def async_update_device(self) -> None: + """Update the Netgear device.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self.async_update_device() + super()._handle_coordinator_update() diff --git a/homeassistant/components/netgear/router.py b/homeassistant/components/netgear/router.py index 2dc86833003097..3c3be7fe9fbbb4 100644 --- a/homeassistant/components/netgear/router.py +++ b/homeassistant/components/netgear/router.py @@ -1,7 +1,6 @@ """Represent the Netgear router and its devices.""" from __future__ import annotations -from abc import abstractmethod import asyncio from datetime import timedelta import logging @@ -17,14 +16,8 @@ CONF_SSL, CONF_USERNAME, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import Entity -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) from homeassistant.util import dt as dt_util from .const import ( @@ -275,137 +268,3 @@ def port(self) -> int: def ssl(self) -> bool: """SSL used by the API.""" return self.api.ssl - - -class NetgearBaseEntity(CoordinatorEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._device = device - self._mac = device["mac"] - self._name = self.get_device_name() - self._device_name = self._name - self._active = device["active"] - - def get_device_name(self): - """Return the name of the given device or the MAC if we don't know.""" - name = self._device["name"] - if not name or name == "--": - name = self._mac - - return name - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - -class NetgearDeviceEntity(NetgearBaseEntity): - """Base class for a device connected to a Netgear router.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter, device: dict - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator, router, device) - self._unique_id = self._mac - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - connections={(dr.CONNECTION_NETWORK_MAC, self._mac)}, - default_name=self._device_name, - default_model=self._device["device_model"], - via_device=(DOMAIN, self._router.unique_id), - ) - - -class NetgearRouterCoordinatorEntity(CoordinatorEntity): - """Base class for a Netgear router entity.""" - - def __init__( - self, coordinator: DataUpdateCoordinator, router: NetgearRouter - ) -> None: - """Initialize a Netgear device.""" - super().__init__(coordinator) - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @abstractmethod - @callback - def async_update_device(self) -> None: - """Update the Netgear device.""" - - @callback - def _handle_coordinator_update(self) -> None: - """Handle updated data from the coordinator.""" - self.async_update_device() - super()._handle_coordinator_update() - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) - - -class NetgearRouterEntity(Entity): - """Base class for a Netgear router entity without coordinator.""" - - def __init__(self, router: NetgearRouter) -> None: - """Initialize a Netgear device.""" - self._router = router - self._name = router.device_name - self._unique_id = router.serial_number - - @property - def unique_id(self) -> str: - """Return a unique ID.""" - return self._unique_id - - @property - def name(self) -> str: - """Return the name.""" - return self._name - - @property - def device_info(self) -> DeviceInfo: - """Return the device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._router.unique_id)}, - ) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 239eca5ff839c8..0de98515a878c3 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -36,7 +36,8 @@ KEY_COORDINATOR_UTIL, KEY_ROUTER, ) -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearDeviceEntity, NetgearRouterCoordinatorEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -379,10 +380,9 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self._attribute = attribute - self.entity_description = SENSOR_TYPES[self._attribute] - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self._attribute}" - self._state = self._device.get(self._attribute) + self.entity_description = SENSOR_TYPES[attribute] + self._attr_unique_id = f"{self._mac}-{attribute}" + self._state = device.get(attribute) @property def native_value(self): @@ -413,8 +413,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}-{entity_description.index}" self._value: StateType | date | datetime | Decimal = None self.async_update_device() diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index 88a89dd32c953e..f594506cbfbc46 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR, KEY_ROUTER -from .router import NetgearDeviceEntity, NetgearRouter, NetgearRouterEntity +from .entity import NetgearDeviceEntity, NetgearRouterEntity +from .router import NetgearRouter _LOGGER = logging.getLogger(__name__) @@ -166,9 +167,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(coordinator, router, device) self.entity_description = entity_description - self._name = f"{self.get_device_name()} {self.entity_description.name}" - self._unique_id = f"{self._mac}-{self.entity_description.key}" - self._attr_is_on = None + self._attr_unique_id = f"{self._mac}-{entity_description.key}" self.async_update_device() async def async_turn_on(self, **kwargs: Any) -> None: @@ -206,8 +205,7 @@ def __init__( """Initialize a Netgear device.""" super().__init__(router) self.entity_description = entity_description - self._name = f"{router.device_name} {entity_description.name}" - self._unique_id = f"{router.serial_number}-{entity_description.key}" + self._attr_unique_id = f"{router.serial_number}-{entity_description.key}" self._attr_is_on = None self._attr_available = False diff --git a/homeassistant/components/netgear/update.py b/homeassistant/components/netgear/update.py index b0e9a26864b227..78e11e7c1743b5 100644 --- a/homeassistant/components/netgear/update.py +++ b/homeassistant/components/netgear/update.py @@ -15,7 +15,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN, KEY_COORDINATOR_FIRMWARE, KEY_ROUTER -from .router import NetgearRouter, NetgearRouterCoordinatorEntity +from .entity import NetgearRouterCoordinatorEntity +from .router import NetgearRouter LOGGER = logging.getLogger(__name__) @@ -44,8 +45,7 @@ def __init__( ) -> None: """Initialize a Netgear device.""" super().__init__(coordinator, router) - self._name = f"{router.device_name} Update" - self._unique_id = f"{router.serial_number}-update" + self._attr_unique_id = f"{router.serial_number}-update" @property def installed_version(self) -> str | None: From f305661dd7761fcdea7028180a265f2c7a865395 Mon Sep 17 00:00:00 2001 From: Jan Rieger Date: Thu, 14 Sep 2023 12:13:19 +0200 Subject: [PATCH 511/640] Change service `set_location` to use number input selectors (#100360) --- .../components/homeassistant/services.yaml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/homeassistant/services.yaml b/homeassistant/components/homeassistant/services.yaml index 09a280133f2185..2b5fd3fc6860cb 100644 --- a/homeassistant/components/homeassistant/services.yaml +++ b/homeassistant/components/homeassistant/services.yaml @@ -7,17 +7,27 @@ set_location: required: true example: 32.87336 selector: - text: + number: + mode: box + min: -90 + max: 90 + step: any longitude: required: true example: 117.22743 selector: - text: + number: + mode: box + min: -180 + max: 180 + step: any elevation: required: false example: 120 selector: - text: + number: + mode: box + step: any stop: toggle: From 8a8f1aff83ecaac120373c0cbc6cd5c41933f0a3 Mon Sep 17 00:00:00 2001 From: Guido Schmitz Date: Thu, 14 Sep 2023 12:35:21 +0200 Subject: [PATCH 512/640] Remove useless timeout guards in devolo_home_network (#100364) --- .../devolo_home_network/__init__.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/devolo_home_network/__init__.py b/homeassistant/components/devolo_home_network/__init__.py index 627a121dcb40f3..d76a6163516f26 100644 --- a/homeassistant/components/devolo_home_network/__init__.py +++ b/homeassistant/components/devolo_home_network/__init__.py @@ -1,7 +1,6 @@ """The devolo Home Network integration.""" from __future__ import annotations -import asyncio import logging from typing import Any @@ -74,8 +73,7 @@ async def async_update_firmware_available() -> UpdateFirmwareCheck: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - return await device.device.async_check_firmware_available() + return await device.device.async_check_firmware_available() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -83,8 +81,7 @@ async def async_update_connected_plc_devices() -> LogicalNetwork: """Fetch data from API endpoint.""" assert device.plcnet try: - async with asyncio.timeout(10): - return await device.plcnet.async_get_network_overview() + return await device.plcnet.async_get_network_overview() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -92,8 +89,7 @@ async def async_update_guest_wifi_status() -> WifiGuestAccessGet: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_guest_access() + return await device.device.async_get_wifi_guest_access() except DeviceUnavailable as err: raise UpdateFailed(err) from err except DevicePasswordProtected as err: @@ -103,8 +99,7 @@ async def async_update_led_status() -> bool: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_led_setting() + return await device.device.async_get_led_setting() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -112,8 +107,7 @@ async def async_update_wifi_connected_station() -> list[ConnectedStationInfo]: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(10): - return await device.device.async_get_wifi_connected_station() + return await device.device.async_get_wifi_connected_station() except DeviceUnavailable as err: raise UpdateFailed(err) from err @@ -121,8 +115,7 @@ async def async_update_wifi_neighbor_access_points() -> list[NeighborAPInfo]: """Fetch data from API endpoint.""" assert device.device try: - async with asyncio.timeout(30): - return await device.device.async_get_wifi_neighbor_access_points() + return await device.device.async_get_wifi_neighbor_access_points() except DeviceUnavailable as err: raise UpdateFailed(err) from err From b85865851636e84af82a2f0e69163902878ace36 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?St=C3=A5le=20Stor=C3=B8=20Hauknes?= Date: Thu, 14 Sep 2023 12:51:06 +0200 Subject: [PATCH 513/640] Fix Airthings ble migration (#100362) * Import Platform for tests * Migration bugfix * Store new unique id as a variable in tests * Add comments to tests --- .../components/airthings_ble/sensor.py | 3 +- tests/components/airthings_ble/test_sensor.py | 45 +++++++++---------- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/airthings_ble/sensor.py b/homeassistant/components/airthings_ble/sensor.py index b66d6b8f8106cb..28b5fa3a7a6752 100644 --- a/homeassistant/components/airthings_ble/sensor.py +++ b/homeassistant/components/airthings_ble/sensor.py @@ -144,7 +144,8 @@ def async_migrate(hass: HomeAssistant, address: str, sensor_name: str) -> None: not matching_reg_entry or "(" not in entry.unique_id ): matching_reg_entry = entry - if not matching_reg_entry: + if not matching_reg_entry or matching_reg_entry.unique_id == new_unique_id: + # Already has the newest unique id format return entity_id = matching_reg_entry.entity_id ent_reg.async_update_entity(entity_id=entity_id, new_unique_id=new_unique_id) diff --git a/tests/components/airthings_ble/test_sensor.py b/tests/components/airthings_ble/test_sensor.py index 68efd4d25f6315..1bf036b735d75d 100644 --- a/tests/components/airthings_ble/test_sensor.py +++ b/tests/components/airthings_ble/test_sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.components.airthings_ble.const import DOMAIN +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from tests.components.airthings_ble import ( @@ -31,11 +32,13 @@ async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): assert entry is not None assert device is not None + new_unique_id = f"{WAVE_DEVICE_INFO.address}_temperature" + entity_registry = hass.helpers.entity_registry.async_get(hass) sensor = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=TEMPERATURE_V1.unique_id, config_entry=entry, device_id=device.id, @@ -57,10 +60,7 @@ async def test_migration_from_v1_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(sensor.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_temperature" - ) + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): @@ -77,7 +77,7 @@ async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): sensor = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=HUMIDITY_V2.unique_id, config_entry=entry, device_id=device.id, @@ -99,10 +99,9 @@ async def test_migration_from_v2_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(sensor.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_humidity" - ) + # Migration should happen, v2 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_humidity" + assert entity_registry.async_get(sensor.entity_id).unique_id == new_unique_id async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): @@ -119,7 +118,7 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): v2 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=CO2_V2.unique_id, config_entry=entry, device_id=device.id, @@ -127,7 +126,7 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): v1 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=CO2_V1.unique_id, config_entry=entry, device_id=device.id, @@ -149,11 +148,10 @@ async def test_migration_from_v1_and_v2_to_v3_unique_id(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert ( - entity_registry.async_get(v1.entity_id).unique_id - == WAVE_DEVICE_INFO.address + "_co2" - ) - assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id + # Migration should happen, v1 unique id should be updated to the new format + new_unique_id = f"{WAVE_DEVICE_INFO.address}_co2" + assert entity_registry.async_get(v1.entity_id).unique_id == new_unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == CO2_V2.unique_id async def test_migration_with_all_unique_ids(hass: HomeAssistant): @@ -170,7 +168,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v1 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V1.unique_id, config_entry=entry, device_id=device.id, @@ -178,7 +176,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v2 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V2.unique_id, config_entry=entry, device_id=device.id, @@ -186,7 +184,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): v3 = entity_registry.async_get_or_create( domain=DOMAIN, - platform="sensor", + platform=Platform.SENSOR, unique_id=VOC_V3.unique_id, config_entry=entry, device_id=device.id, @@ -208,6 +206,7 @@ async def test_migration_with_all_unique_ids(hass: HomeAssistant): assert len(hass.states.async_all()) > 0 - assert entity_registry.async_get(v1.entity_id).unique_id == v1.unique_id - assert entity_registry.async_get(v2.entity_id).unique_id == v2.unique_id - assert entity_registry.async_get(v3.entity_id).unique_id == v3.unique_id + # No migration should happen, unique id should be the same as before + assert entity_registry.async_get(v1.entity_id).unique_id == VOC_V1.unique_id + assert entity_registry.async_get(v2.entity_id).unique_id == VOC_V2.unique_id + assert entity_registry.async_get(v3.entity_id).unique_id == VOC_V3.unique_id From 6fc14076132beb3973bdea4bbc56005b5374fec4 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 13:31:54 +0200 Subject: [PATCH 514/640] Extract Withings API specifics in own class (#100363) * Extract Withings API specifics in own class * Extract Withings API specifics in own class * Ignore api test coverage * fix feedback --- .coveragerc | 1 + homeassistant/components/withings/api.py | 167 ++++++++++++++++++++ homeassistant/components/withings/common.py | 147 ++++------------- tests/components/withings/conftest.py | 8 +- tests/components/withings/test_init.py | 16 +- 5 files changed, 214 insertions(+), 125 deletions(-) create mode 100644 homeassistant/components/withings/api.py diff --git a/.coveragerc b/.coveragerc index 4835ec5a05bbc7..3c7ade54b0e5f5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1481,6 +1481,7 @@ 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/api.py b/homeassistant/components/withings/api.py new file mode 100644 index 00000000000000..fff9767ebdafa7 --- /dev/null +++ b/homeassistant/components/withings/api.py @@ -0,0 +1,167 @@ +"""Api for Withings.""" +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +import logging +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 LOG_NAMESPACE + +_LOGGER = logging.getLogger(LOG_NAMESPACE) +_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, 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.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.measure_get_meas, + meastype, + category, + startdate, + enddate, + offset, + lastupdate, + ) + ) + + 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.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.sleep_get_summary, + data_fields, + startdateymd, + enddateymd, + offset, + lastupdate, + ) + ) + + async def async_notify_list( + self, appli: NotifyAppli | None = None + ) -> NotifyListResponse: + """List webhooks.""" + + return await self._do_retry( + await self._hass.async_add_executor_job(self.notify_list, appli) + ) + + async def async_notify_subscribe( + self, + callbackurl: str, + appli: NotifyAppli | None = None, + comment: str | None = None, + ) -> None: + """Subscribe to webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_subscribe, callbackurl, appli, comment + ) + ) + + async def async_notify_revoke( + self, callbackurl: str | None = None, appli: NotifyAppli | None = None + ) -> None: + """Revoke webhook.""" + + return await self._do_retry( + await self._hass.async_add_executor_job( + self.notify_revoke, callbackurl, appli + ) + ) diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 98c98f1fa9697c..446fb4b58e5351 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -13,8 +13,6 @@ from typing import Any from aiohttp.web import Response -import requests -from withings_api import AbstractWithingsApi from withings_api.common import ( AuthFailedException, GetSleepSummaryField, @@ -22,7 +20,6 @@ MeasureType, MeasureTypes, NotifyAppli, - SleepGetSummaryResponse, UnauthorizedException, query_measure_groups, ) @@ -33,18 +30,14 @@ from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import config_entry_oauth2_flow -from homeassistant.helpers.config_entry_oauth2_flow import ( - AbstractOAuth2Implementation, - OAuth2Session, -) from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.util import dt as dt_util from . import const +from .api import ConfigEntryWithingsApi from .const import Measurement _LOGGER = logging.getLogger(const.LOG_NAMESPACE) -_RETRY_COEFFICIENT = 0.5 NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, @@ -114,40 +107,6 @@ class WebhookConfig: } -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() - - def json_message_response(message: str, message_code: int) -> Response: """Produce common json output.""" return HomeAssistantView.json({"message": message, "code": message_code}) @@ -271,34 +230,8 @@ def async_stop_polling_webhook_subscriptions(self) -> None: self._cancel_subscription_update() self._cancel_subscription_update = None - async def _do_retry(self, func, 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_subscribe_webhook(self) -> None: """Subscribe the webhook to withings data updates.""" - return await self._do_retry(self._async_subscribe_webhook) - - async def _async_subscribe_webhook(self) -> None: _LOGGER.debug("Configuring withings webhook") # On first startup, perform a fresh re-subscribe. Withings stops pushing data @@ -311,7 +244,7 @@ async def _async_subscribe_webhook(self) -> None: self._subscribe_webhook_run_count += 1 # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() subscribed_applis = frozenset( profile.appli @@ -338,17 +271,12 @@ async def _async_subscribe_webhook(self) -> None: # 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(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_subscribe, self._webhook_config.url, appli - ) + await self._api.async_notify_subscribe(self._webhook_config.url, appli) async def async_unsubscribe_webhook(self) -> None: """Unsubscribe webhook from withings data updates.""" - return await self._do_retry(self._async_unsubscribe_webhook) - - async def _async_unsubscribe_webhook(self) -> None: # Get the current webhooks. - response = await self._hass.async_add_executor_job(self._api.notify_list) + response = await self._api.async_notify_list() # Revoke subscriptions. for profile in response.profiles: @@ -361,14 +289,15 @@ async def _async_unsubscribe_webhook(self) -> None: # Quick calls to Withings can result in the service returning errors. # Give them some time to cool down. await asyncio.sleep(self._notify_subscribe_delay.total_seconds()) - await self._hass.async_add_executor_job( - self._api.notify_revoke, profile.callbackurl, profile.appli - ) + await self._api.async_notify_revoke(profile.callbackurl, profile.appli) async def async_get_all_data(self) -> dict[MeasureType, Any] | None: """Update all withings data.""" try: - return await self._do_retry(self._async_get_all_data) + return { + **await self.async_get_measures(), + **await self.async_get_sleep_summary(), + } except Exception as exception: # User is not authenticated. if isinstance( @@ -379,21 +308,14 @@ async def async_get_all_data(self) -> dict[MeasureType, Any] | None: raise exception - async def _async_get_all_data(self) -> dict[Measurement, Any] | None: - _LOGGER.info("Updating all withings data") - return { - **await self.async_get_measures(), - **await self.async_get_sleep_summary(), - } - async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" _LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) - response = await self._hass.async_add_executor_job( - self._api.measure_get_meas, None, None, startdate, now, None, startdate + response = await self._api.async_measure_get_meas( + None, None, startdate, now, None, startdate ) # Sort from oldest to newest. @@ -424,31 +346,28 @@ async def async_get_sleep_summary(self) -> dict[Measurement, Any]: ) yesterday_noon_utc = dt_util.as_utc(yesterday_noon) - def get_sleep_summary() -> SleepGetSummaryResponse: - return self._api.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._hass.async_add_executor_job(get_sleep_summary) + response = await self._api.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, + ], + ) # Set the default to empty lists. raw_values: dict[GetSleepSummaryField, list[int]] = { diff --git a/tests/components/withings/conftest.py b/tests/components/withings/conftest.py index a5e51c68c40df6..f1df0e3a65a336 100644 --- a/tests/components/withings/conftest.py +++ b/tests/components/withings/conftest.py @@ -15,7 +15,7 @@ ClientCredential, async_import_client_credential, ) -from homeassistant.components.withings.common import ConfigEntryWithingsApi +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 @@ -112,13 +112,13 @@ def mock_withings(): mock.user_get_device.return_value = UserGetDeviceResponse( **load_json_object_fixture("withings/get_device.json") ) - mock.measure_get_meas.return_value = MeasureGetMeasResponse( + mock.async_measure_get_meas.return_value = MeasureGetMeasResponse( **load_json_object_fixture("withings/get_meas.json") ) - mock.sleep_get_summary.return_value = SleepGetSummaryResponse( + mock.async_sleep_get_summary.return_value = SleepGetSummaryResponse( **load_json_object_fixture("withings/get_sleep.json") ) - mock.notify_list.return_value = NotifyListResponse( + mock.async_notify_list.return_value = NotifyListResponse( **load_json_object_fixture("withings/notify_list.json") ) diff --git a/tests/components/withings/test_init.py b/tests/components/withings/test_init.py index 4e7eb812f0a6bd..15f0fff808d88e 100644 --- a/tests/components/withings/test_init.py +++ b/tests/components/withings/test_init.py @@ -117,17 +117,19 @@ 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.notify_subscribe.call_count == 4 + assert withings.async_notify_subscribe.call_count == 4 webhook_url = "http://example.local:8123/api/webhook/55a7335ea8dee830eed4ef8f84cda8f6d80b83af0847dc74032e86120bffed5e" - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.CIRCULATORY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) - withings.notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.WEIGHT) + withings.async_notify_subscribe.assert_any_call( + webhook_url, NotifyAppli.CIRCULATORY + ) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.ACTIVITY) + withings.async_notify_subscribe.assert_any_call(webhook_url, NotifyAppli.SLEEP) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) - withings.notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_IN) + withings.async_notify_revoke.assert_any_call(webhook_url, NotifyAppli.BED_OUT) @pytest.mark.parametrize( From 7ea2087c452e51c5ce290a03a473984ef7f023a3 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 13:58:53 +0200 Subject: [PATCH 515/640] Add Netgear entity translations (#100367) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/netgear/sensor.py | 54 ++++----- homeassistant/components/netgear/strings.json | 111 ++++++++++++++++++ homeassistant/components/netgear/switch.py | 16 +-- 3 files changed, 146 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/netgear/sensor.py b/homeassistant/components/netgear/sensor.py index 0de98515a878c3..6e7771d44cb968 100644 --- a/homeassistant/components/netgear/sensor.py +++ b/homeassistant/components/netgear/sensor.py @@ -44,33 +44,33 @@ SENSOR_TYPES = { "type": SensorEntityDescription( key="type", - name="link type", + translation_key="link_type", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:lan", ), "link_rate": SensorEntityDescription( key="link_rate", - name="link rate", + translation_key="link_rate", native_unit_of_measurement="Mbps", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:speedometer", ), "signal": SensorEntityDescription( key="signal", - name="signal strength", + translation_key="signal_strength", native_unit_of_measurement=PERCENTAGE, entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi", ), "ssid": SensorEntityDescription( key="ssid", - name="ssid", + translation_key="ssid", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:wifi-marker", ), "conn_ap_mac": SensorEntityDescription( key="conn_ap_mac", - name="access point mac", + translation_key="access_point_mac", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:router-network", ), @@ -88,7 +88,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_TRAFFIC_TYPES = [ NetgearSensorEntityDescription( key="NewTodayUpload", - name="Upload today", + translation_key="upload_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -96,7 +96,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewTodayDownload", - name="Download today", + translation_key="download_today", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -104,7 +104,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewYesterdayUpload", - name="Upload yesterday", + translation_key="upload_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -112,7 +112,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewYesterdayDownload", - name="Download yesterday", + translation_key="download_yesterday", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -120,7 +120,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week", + translation_key="upload_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -130,7 +130,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekUpload", - name="Upload week average", + translation_key="upload_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -140,7 +140,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week", + translation_key="download_week", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -150,7 +150,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewWeekDownload", - name="Download week average", + translation_key="download_week_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -160,7 +160,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month", + translation_key="upload_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -170,7 +170,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthUpload", - name="Upload month average", + translation_key="upload_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -180,7 +180,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month", + translation_key="download_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -190,7 +190,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMonthDownload", - name="Download month average", + translation_key="download_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -200,7 +200,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month", + translation_key="upload_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -210,7 +210,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthUpload", - name="Upload last month average", + translation_key="upload_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -220,7 +220,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month", + translation_key="download_last_month", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -230,7 +230,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewLastMonthDownload", - name="Download last month average", + translation_key="download_last_month_average", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfInformation.MEGABYTES, device_class=SensorDeviceClass.DATA_SIZE, @@ -243,7 +243,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_SPEED_TYPES = [ NetgearSensorEntityDescription( key="NewOOKLAUplinkBandwidth", - name="Uplink Bandwidth", + translation_key="uplink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -251,7 +251,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewOOKLADownlinkBandwidth", - name="Downlink Bandwidth", + translation_key="downlink_bandwidth", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, device_class=SensorDeviceClass.DATA_RATE, @@ -259,7 +259,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="AveragePing", - name="Average Ping", + translation_key="average_ping", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=UnitOfTime.MILLISECONDS, icon="mdi:wan", @@ -269,7 +269,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_UTILIZATION = [ NetgearSensorEntityDescription( key="NewCPUUtilization", - name="CPU Utilization", + translation_key="cpu_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:cpu-64-bit", @@ -277,7 +277,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): ), NetgearSensorEntityDescription( key="NewMemoryUtilization", - name="Memory Utilization", + translation_key="memory_utilization", entity_category=EntityCategory.DIAGNOSTIC, native_unit_of_measurement=PERCENTAGE, icon="mdi:memory", @@ -288,7 +288,7 @@ class NetgearSensorEntityDescription(SensorEntityDescription): SENSOR_LINK_TYPES = [ NetgearSensorEntityDescription( key="NewEthernetLinkStatus", - name="Ethernet Link Status", + translation_key="ethernet_link_status", entity_category=EntityCategory.DIAGNOSTIC, icon="mdi:ethernet", ), diff --git a/homeassistant/components/netgear/strings.json b/homeassistant/components/netgear/strings.json index a903535d5a8780..6b4883b8ce31fc 100644 --- a/homeassistant/components/netgear/strings.json +++ b/homeassistant/components/netgear/strings.json @@ -29,5 +29,116 @@ } } } + }, + "entity": { + "sensor": { + "link_type": { + "name": "Link type" + }, + "link_rate": { + "name": "Link rate" + }, + "signal_strength": { + "name": "[%key:component::sensor::entity_component::signal_strength::name%]" + }, + "ssid": { + "name": "SSID" + }, + "access_point_mac": { + "name": "Access point mac" + }, + "upload_today": { + "name": "Upload today" + }, + "download_today": { + "name": "Download today" + }, + "upload_yesterday": { + "name": "Upload yesterday" + }, + "download_yesterday": { + "name": "Download yesterday" + }, + "upload_week": { + "name": "Upload this week" + }, + "upload_week_average": { + "name": "Upload this week average" + }, + "download_week": { + "name": "Download this week" + }, + "download_week_average": { + "name": "Download this week average" + }, + "upload_month": { + "name": "Upload this month" + }, + "upload_month_average": { + "name": "Upload this month average" + }, + "download_month": { + "name": "Download this month" + }, + "download_month_average": { + "name": "Download this month average" + }, + "upload_last_month": { + "name": "Upload last month" + }, + "upload_last_month_average": { + "name": "Upload last month average" + }, + "download_last_month": { + "name": "Download last month" + }, + "download_last_month_average": { + "name": "Download last month average" + }, + "uplink_bandwidth": { + "name": "Uplink bandwidth" + }, + "downlink_bandwidth": { + "name": "Downlink bandwidth" + }, + "average_ping": { + "name": "Average ping" + }, + "cpu_utilization": { + "name": "CPU utilization" + }, + "memory_utilization": { + "name": "Memory utilization" + }, + "ethernet_link_status": { + "name": "Ethernet link status" + } + }, + "switch": { + "allowed_on_network": { + "name": "Allowed on network" + }, + "access_control": { + "name": "Access control" + }, + "traffic_meter": { + "name": "Traffic meter" + }, + "parental_control": { + "name": "Parental control" + }, + "quality_of_service": { + "name": "Quality of service" + }, + "2g_guest_wifi": { + "name": "2.4GHz guest Wi-Fi" + }, + "5g_guest_wifi": { + "name": "5GHz guest Wi-Fi" + }, + "smart_connect": { + "name": "Smart connect" + } + } } } diff --git a/homeassistant/components/netgear/switch.py b/homeassistant/components/netgear/switch.py index f594506cbfbc46..a4548da16a4c56 100644 --- a/homeassistant/components/netgear/switch.py +++ b/homeassistant/components/netgear/switch.py @@ -25,7 +25,7 @@ SWITCH_TYPES = [ SwitchEntityDescription( key="allow_or_block", - name="Allowed on network", + translation_key="allowed_on_network", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, ) @@ -50,7 +50,7 @@ class NetgearSwitchEntityDescription( ROUTER_SWITCH_TYPES = [ NetgearSwitchEntityDescription( key="access_control", - name="Access Control", + translation_key="access_control", icon="mdi:block-helper", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_block_device_enable_status, @@ -58,7 +58,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="traffic_meter", - name="Traffic Meter", + translation_key="traffic_meter", icon="mdi:wifi-arrow-up-down", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_traffic_meter_enabled, @@ -66,7 +66,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="parental_control", - name="Parental Control", + translation_key="parental_control", icon="mdi:account-child-outline", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_parental_control_enable_status, @@ -74,7 +74,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="qos", - name="Quality of Service", + translation_key="quality_of_service", icon="mdi:wifi-star", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_qos_enable_status, @@ -82,7 +82,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="2g_guest_wifi", - name="2.4G Guest Wifi", + translation_key="2g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_2g_guest_access_enabled, @@ -90,7 +90,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="5g_guest_wifi", - name="5G Guest Wifi", + translation_key="5g_guest_wifi", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_5g_guest_access_enabled, @@ -98,7 +98,7 @@ class NetgearSwitchEntityDescription( ), NetgearSwitchEntityDescription( key="smart_connect", - name="Smart Connect", + translation_key="smart_connect", icon="mdi:wifi", entity_category=EntityCategory.CONFIG, update=lambda router: router.api.get_smart_connect_enabled, From d4a2927ebe28c1309250058f0c194ff69419b220 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Thu, 14 Sep 2023 16:03:32 +0200 Subject: [PATCH 516/640] Solve racing problem in modbus test (#100287) * Test racing problem. * review comment. * Revert to approved state. This reverts commit 983d9d68e8f77bae33ef4f8f1ac8c31cddfa6dca. --- tests/components/modbus/test_init.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5d419ed28d587e..c2f3e6395805c8 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -884,7 +884,7 @@ async def test_stop_restart( caplog.set_level(logging.INFO) entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") - assert hass.states.get(entity_id).state == STATE_UNKNOWN + assert hass.states.get(entity_id).state in (STATE_UNKNOWN, STATE_UNAVAILABLE) hass.states.async_set(entity_id, 17) await hass.async_block_till_done() assert hass.states.get(entity_id).state == "17" From 89eec9990b2fdb152d52aa2bbc83e4a12ed0508d Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 16:55:47 +0200 Subject: [PATCH 517/640] Use shorthand device_type attr for plaato sensors (#100385) --- .../components/plaato/binary_sensor.py | 19 ++++++++----------- homeassistant/components/plaato/sensor.py | 16 +++++----------- 2 files changed, 13 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/plaato/binary_sensor.py b/homeassistant/components/plaato/binary_sensor.py index a8b7dc51c1e199..e8fbaa5d6f15d5 100644 --- a/homeassistant/components/plaato/binary_sensor.py +++ b/homeassistant/components/plaato/binary_sensor.py @@ -39,20 +39,17 @@ async def async_setup_entry( class PlaatoBinarySensor(PlaatoEntity, BinarySensorEntity): """Representation of a Binary Sensor.""" + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato binary sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: + self._attr_device_class = BinarySensorDeviceClass.PROBLEM + elif sensor_type is PlaatoKeg.Pins.POURING: + self._attr_device_class = BinarySensorDeviceClass.OPENING + @property def is_on(self): """Return true if the binary sensor is on.""" if self._coordinator is not None: return self._coordinator.data.binary_sensors.get(self._sensor_type) return False - - @property - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this device, from BinarySensorDeviceClass.""" - if self._coordinator is None: - return None - if self._sensor_type is PlaatoKeg.Pins.LEAK_DETECTION: - return BinarySensorDeviceClass.PROBLEM - if self._sensor_type is PlaatoKeg.Pins.POURING: - return BinarySensorDeviceClass.OPENING - return None diff --git a/homeassistant/components/plaato/sensor.py b/homeassistant/components/plaato/sensor.py index b43e18e52f62d2..f3d9a5c3e41154 100644 --- a/homeassistant/components/plaato/sensor.py +++ b/homeassistant/components/plaato/sensor.py @@ -72,17 +72,11 @@ async def _async_update_from_webhook(device_id, sensor_data: PlaatoDevice): class PlaatoSensor(PlaatoEntity, SensorEntity): """Representation of a Plaato Sensor.""" - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the class of this device, from SensorDeviceClass.""" - if ( - self._coordinator is not None - and self._sensor_type == PlaatoKeg.Pins.TEMPERATURE - ): - return SensorDeviceClass.TEMPERATURE - if self._sensor_type == ATTR_TEMP: - return SensorDeviceClass.TEMPERATURE - return None + def __init__(self, data, sensor_type, coordinator=None) -> None: + """Initialize plaato sensor.""" + super().__init__(data, sensor_type, coordinator) + if sensor_type is PlaatoKeg.Pins.TEMPERATURE or sensor_type == ATTR_TEMP: + self._attr_device_class = SensorDeviceClass.TEMPERATURE @property def native_value(self): From 8b7061b6341c8025705a667509d7c2a5821689fd Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 18:10:31 +0200 Subject: [PATCH 518/640] Short handed device class for overkiz cover (#100394) --- .../overkiz/cover_entities/vertical_cover.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/overkiz/cover_entities/vertical_cover.py b/homeassistant/components/overkiz/cover_entities/vertical_cover.py index 6e72dacf5c6d62..2bc6f73103fe0e 100644 --- a/homeassistant/components/overkiz/cover_entities/vertical_cover.py +++ b/homeassistant/components/overkiz/cover_entities/vertical_cover.py @@ -45,6 +45,17 @@ class VerticalCover(OverkizGenericCover): """Representation of an Overkiz vertical cover.""" + def __init__( + self, device_url: str, coordinator: OverkizDataUpdateCoordinator + ) -> None: + """Initialize vertical cover.""" + super().__init__(device_url, coordinator) + self._attr_device_class = ( + OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) + or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) + or CoverDeviceClass.BLIND + ) + @property def supported_features(self) -> CoverEntityFeature: """Flag supported features.""" @@ -64,15 +75,6 @@ def supported_features(self) -> CoverEntityFeature: return supported_features - @property - def device_class(self) -> CoverDeviceClass: - """Return the class of the device.""" - return ( - OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.widget) - or OVERKIZ_DEVICE_TO_DEVICE_CLASS.get(self.device.ui_class) - or CoverDeviceClass.BLIND - ) - @property def current_cover_position(self) -> int | None: """Return current position of cover. From 6701a449bd77c999d6ed185fd96af49c806712a3 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 18:17:23 +0200 Subject: [PATCH 519/640] Use shorthand attrs for tasmota (#100390) --- homeassistant/components/tasmota/mixins.py | 10 +--- homeassistant/components/tasmota/sensor.py | 69 +++++++--------------- 2 files changed, 23 insertions(+), 56 deletions(-) diff --git a/homeassistant/components/tasmota/mixins.py b/homeassistant/components/tasmota/mixins.py index e99106d09e89a6..21030b8c14b07c 100644 --- a/homeassistant/components/tasmota/mixins.py +++ b/homeassistant/components/tasmota/mixins.py @@ -38,6 +38,9 @@ def __init__(self, tasmota_entity: HATasmotaEntity) -> None: """Initialize.""" self._tasmota_entity = tasmota_entity self._unique_id = tasmota_entity.unique_id + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, tasmota_entity.mac)} + ) async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" @@ -61,13 +64,6 @@ async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" await self._tasmota_entity.subscribe_topics() - @property - def device_info(self) -> DeviceInfo: - """Return a device description for device registry.""" - return DeviceInfo( - connections={(CONNECTION_NETWORK_MAC, self._tasmota_entity.mac)} - ) - @property def name(self) -> str | None: """Return the name of the binary sensor.""" diff --git a/homeassistant/components/tasmota/sensor.py b/homeassistant/components/tasmota/sensor.py index e718c0fdcf4f37..29d3f5c8c8a0a2 100644 --- a/homeassistant/components/tasmota/sensor.py +++ b/homeassistant/components/tasmota/sensor.py @@ -274,6 +274,26 @@ def __init__(self, **kwds: Any) -> None: **kwds, ) + class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( + self._tasmota_entity.quantity, {} + ) + self._attr_device_class = class_or_icon.get(DEVICE_CLASS) + self._attr_state_class = class_or_icon.get(STATE_CLASS) + if self._tasmota_entity.quantity in status_sensor.SENSORS: + self._attr_entity_category = EntityCategory.DIAGNOSTIC + # Hide fast changing status sensors + if self._tasmota_entity.quantity in ( + hc.SENSOR_STATUS_IP, + hc.SENSOR_STATUS_RSSI, + hc.SENSOR_STATUS_SIGNAL, + hc.SENSOR_STATUS_VERSION, + ): + self._attr_entity_registry_enabled_default = False + self._attr_icon = class_or_icon.get(ICON) + self._attr_native_unit_of_measurement = SENSOR_UNIT_MAP.get( + self._tasmota_entity.unit, self._tasmota_entity.unit + ) + async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" self._tasmota_entity.set_on_state_callback(self.sensor_state_updated) @@ -288,58 +308,9 @@ def sensor_state_updated(self, state: Any, **kwargs: Any) -> None: self._state = state self.async_write_ha_state() - @property - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(DEVICE_CLASS) - - @property - def state_class(self) -> str | None: - """Return the state class of the sensor.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(STATE_CLASS) - - @property - def entity_category(self) -> EntityCategory | None: - """Return the category of the entity, if any.""" - if self._tasmota_entity.quantity in status_sensor.SENSORS: - return EntityCategory.DIAGNOSTIC - return None - - @property - def entity_registry_enabled_default(self) -> bool: - """Return if the entity should be enabled when first added to the entity registry.""" - # Hide fast changing status sensors - if self._tasmota_entity.quantity in ( - hc.SENSOR_STATUS_IP, - hc.SENSOR_STATUS_RSSI, - hc.SENSOR_STATUS_SIGNAL, - hc.SENSOR_STATUS_VERSION, - ): - return False - return True - - @property - def icon(self) -> str | None: - """Return the icon.""" - class_or_icon = SENSOR_DEVICE_CLASS_ICON_MAP.get( - self._tasmota_entity.quantity, {} - ) - return class_or_icon.get(ICON) - @property def native_value(self) -> datetime | str | None: """Return the state of the entity.""" if self._state_timestamp and self.device_class == SensorDeviceClass.TIMESTAMP: return self._state_timestamp return self._state - - @property - def native_unit_of_measurement(self) -> str | None: - """Return the unit this state is expressed in.""" - return SENSOR_UNIT_MAP.get(self._tasmota_entity.unit, self._tasmota_entity.unit) From 1d4b731603125bb1e5b505dc0a7d23a701179d5e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 12:40:47 -0500 Subject: [PATCH 520/640] Bump zeroconf to 0.112.0 (#100386) --- 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 34cf72f180d4aa..d81ed1dfaaa382 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.111.0"] + "requirements": ["zeroconf==0.112.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 8beeae2f96076a..0952b33978864c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -53,7 +53,7 @@ voluptuous-serialize==2.6.0 voluptuous==0.13.1 webrtcvad==2.0.10 yarl==1.9.2 -zeroconf==0.111.0 +zeroconf==0.112.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 133d43bc2f0142..2ebb45937b7664 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2769,7 +2769,7 @@ zamg==0.3.0 zengge==0.2 # homeassistant.components.zeroconf -zeroconf==0.111.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9b2912119aabe8..33440afc6b6849 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2048,7 +2048,7 @@ youtubeaio==1.1.5 zamg==0.3.0 # homeassistant.components.zeroconf -zeroconf==0.111.0 +zeroconf==0.112.0 # homeassistant.components.zeversolar zeversolar==0.3.1 From f90919912578eea4e721b9f5d865b79c73937cc7 Mon Sep 17 00:00:00 2001 From: Robert Svensson Date: Thu, 14 Sep 2023 20:13:46 +0200 Subject: [PATCH 521/640] Remove hard coded Icon from Unifi device scanner (#100401) --- homeassistant/components/unifi/device_tracker.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/unifi/device_tracker.py b/homeassistant/components/unifi/device_tracker.py index 746e3b1fcf00eb..22a530e0369727 100644 --- a/homeassistant/components/unifi/device_tracker.py +++ b/homeassistant/components/unifi/device_tracker.py @@ -179,7 +179,6 @@ class UnifiTrackerEntityDescription( UnifiTrackerEntityDescription[Devices, Device]( key="Device scanner", has_entity_name=True, - icon="mdi:ethernet", allowed_fn=lambda controller, obj_id: controller.option_track_devices, api_handler_fn=lambda api: api.devices, available_fn=async_device_available_fn, From 3f2a660dabededb44900dfd0f9c245dacab71373 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Thu, 14 Sep 2023 21:24:23 +0200 Subject: [PATCH 522/640] Bump reolink-aio to 0.7.10 (#100376) --- homeassistant/components/reolink/manifest.json | 2 +- homeassistant/components/reolink/strings.json | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/reolink/manifest.json b/homeassistant/components/reolink/manifest.json index 060490c6e565be..221a6b8b59d627 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.9"] + "requirements": ["reolink-aio==0.7.10"] } diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 95aa26a1ff53d9..15ba4baed45733 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -223,6 +223,7 @@ "state": { "off": "[%key:common::state::off%]", "auto": "Auto", + "onatnight": "On at night", "schedule": "Schedule", "adaptive": "Adaptive", "autoadaptive": "Auto adaptive" diff --git a/requirements_all.txt b/requirements_all.txt index 2ebb45937b7664..8ca4f1aabbdc2c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2298,7 +2298,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.9 +reolink-aio==0.7.10 # homeassistant.components.idteck_prox rfk101py==0.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 33440afc6b6849..8d225e3d9ada05 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1697,7 +1697,7 @@ renault-api==0.2.0 renson-endura-delta==1.5.0 # homeassistant.components.reolink -reolink-aio==0.7.9 +reolink-aio==0.7.10 # homeassistant.components.rflink rflink==0.0.65 From a62f16b4cc7a20fe6162d8ad858aa08423466e19 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 21:41:34 +0200 Subject: [PATCH 523/640] Remove obsolete strings from Withings (#100396) --- homeassistant/components/withings/strings.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/homeassistant/components/withings/strings.json b/homeassistant/components/withings/strings.json index 5fa155a1c1c7e6..22718b305ecefe 100644 --- a/homeassistant/components/withings/strings.json +++ b/homeassistant/components/withings/strings.json @@ -1,18 +1,12 @@ { "config": { - "flow_title": "{profile}", "step": { - "profile": { - "title": "User Profile.", - "description": "Provide a unique profile name for this data. Typically this is the name of the profile you selected in the previous step.", - "data": { "profile": "Profile Name" } - }, "pick_implementation": { "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" }, "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", - "description": "The \"{profile}\" profile needs to be re-authenticated in order to continue receiving Withings data." + "description": "The Withings integration needs to re-authenticate your account" } }, "error": { From 157647dc440c8f3081836760adec4c33b18c60a3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 14 Sep 2023 21:52:21 +0200 Subject: [PATCH 524/640] Move solarlog coordinator to own file (#100402) --- .coveragerc | 1 + homeassistant/components/solarlog/__init__.py | 55 +----------------- .../components/solarlog/coordinator.py | 56 +++++++++++++++++++ 3 files changed, 59 insertions(+), 53 deletions(-) create mode 100644 homeassistant/components/solarlog/coordinator.py diff --git a/.coveragerc b/.coveragerc index 3c7ade54b0e5f5..015d1c541e93a0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1157,6 +1157,7 @@ omit = homeassistant/components/solaredge_local/sensor.py homeassistant/components/solarlog/__init__.py homeassistant/components/solarlog/sensor.py + homeassistant/components/solarlog/coordinator.py homeassistant/components/solax/__init__.py homeassistant/components/solax/sensor.py homeassistant/components/soma/__init__.py diff --git a/homeassistant/components/solarlog/__init__.py b/homeassistant/components/solarlog/__init__.py index e0ab838922b66c..95cf5cc45675b3 100644 --- a/homeassistant/components/solarlog/__init__.py +++ b/homeassistant/components/solarlog/__init__.py @@ -1,19 +1,10 @@ """Solar-Log integration.""" -from datetime import timedelta -import logging -from urllib.parse import ParseResult, urlparse - -from requests.exceptions import HTTPError, Timeout -from sunwatcher.solarlog.solarlog import SolarLog - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import update_coordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import SolarlogData PLATFORMS = [Platform.SENSOR] @@ -30,45 +21,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class SolarlogData(update_coordinator.DataUpdateCoordinator): - """Get and update the latest data.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the data object.""" - super().__init__( - hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) - ) - - host_entry = entry.data[CONF_HOST] - - url = urlparse(host_entry, "http") - netloc = url.netloc or url.path - path = url.path if url.netloc else "" - url = ParseResult("http", netloc, path, *url[3:]) - self.unique_id = entry.entry_id - self.name = entry.title - self.host = url.geturl() - - async def _async_update_data(self): - """Update the data from the SolarLog device.""" - try: - data = await self.hass.async_add_executor_job(SolarLog, self.host) - except (OSError, Timeout, HTTPError) as err: - raise update_coordinator.UpdateFailed(err) from err - - if data.time.year == 1999: - raise update_coordinator.UpdateFailed( - "Invalid data returned (can happen after Solarlog restart)." - ) - - self.logger.debug( - ( - "Connection to Solarlog successful. Retrieving latest Solarlog update" - " of %s" - ), - data.time, - ) - - return data diff --git a/homeassistant/components/solarlog/coordinator.py b/homeassistant/components/solarlog/coordinator.py new file mode 100644 index 00000000000000..d363256f355a8a --- /dev/null +++ b/homeassistant/components/solarlog/coordinator.py @@ -0,0 +1,56 @@ +"""DataUpdateCoordinator for solarlog integration.""" +from datetime import timedelta +import logging +from urllib.parse import ParseResult, urlparse + +from requests.exceptions import HTTPError, Timeout +from sunwatcher.solarlog.solarlog import SolarLog + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.helpers import update_coordinator + +_LOGGER = logging.getLogger(__name__) + + +class SolarlogData(update_coordinator.DataUpdateCoordinator): + """Get and update the latest data.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the data object.""" + super().__init__( + hass, _LOGGER, name="SolarLog", update_interval=timedelta(seconds=60) + ) + + host_entry = entry.data[CONF_HOST] + + url = urlparse(host_entry, "http") + netloc = url.netloc or url.path + path = url.path if url.netloc else "" + url = ParseResult("http", netloc, path, *url[3:]) + self.unique_id = entry.entry_id + self.name = entry.title + self.host = url.geturl() + + async def _async_update_data(self): + """Update the data from the SolarLog device.""" + try: + data = await self.hass.async_add_executor_job(SolarLog, self.host) + except (OSError, Timeout, HTTPError) as err: + raise update_coordinator.UpdateFailed(err) from err + + if data.time.year == 1999: + raise update_coordinator.UpdateFailed( + "Invalid data returned (can happen after Solarlog restart)." + ) + + self.logger.debug( + ( + "Connection to Solarlog successful. Retrieving latest Solarlog update" + " of %s" + ), + data.time, + ) + + return data From c34c4f8f0393a9b5a1bd1c47df887a7d0f7c2c2a Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Thu, 14 Sep 2023 21:54:49 +0200 Subject: [PATCH 525/640] Reload on Withings options flow update (#100397) * Reload on Withings options flow update * Remove reload from reauth --- homeassistant/components/withings/__init__.py | 6 ++++++ homeassistant/components/withings/config_flow.py | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 841c9da3c702f2..589bfe79094a79 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -150,6 +150,7 @@ def async_call_later_callback(now) -> None: entry.async_on_unload(async_call_later(hass, 1, async_call_later_callback)) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(update_listener)) return True @@ -171,6 +172,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return True +async def update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + await hass.config_entries.async_reload(entry.entry_id) + + async def async_webhook_handler( hass: HomeAssistant, webhook_id: str, request: Request ) -> Response | None: diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index f25ef95210c369..cce1c5ee23ccd0 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -83,7 +83,6 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: if self.reauth_entry.unique_id == user_id: 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") return self.async_abort(reason="wrong_account") From 23faa0882f023d18e7a658ce0711b2a782fa0852 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 14 Sep 2023 22:10:28 +0200 Subject: [PATCH 526/640] Avoid multiline ternary use (#100381) --- homeassistant/components/iaqualink/sensor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/iaqualink/sensor.py b/homeassistant/components/iaqualink/sensor.py index b18a85a43a50d8..15e8fc5836d0af 100644 --- a/homeassistant/components/iaqualink/sensor.py +++ b/homeassistant/components/iaqualink/sensor.py @@ -34,13 +34,13 @@ def __init__(self, dev: AqualinkSensor) -> None: """Initialize AquaLink sensor.""" super().__init__(dev) self._attr_name = dev.label - if dev.name.endswith("_temp"): - self._attr_native_unit_of_measurement = ( - UnitOfTemperature.FAHRENHEIT - if dev.system.temp_unit == "F" - else UnitOfTemperature.CELSIUS - ) - self._attr_device_class = SensorDeviceClass.TEMPERATURE + if not dev.name.endswith("_temp"): + return + self._attr_device_class = SensorDeviceClass.TEMPERATURE + if dev.system.temp_unit == "F": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + return + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property def native_value(self) -> int | float | None: From df74ed0d40f04d4a5fb73ac4c9cdf0121bca7c3f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 15:13:15 -0500 Subject: [PATCH 527/640] Bump bleak-retry-connector to 3.2.1 (#100377) --- 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 7908dbbad66d75..54f10fbc0c7cb7 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.1.3", + "bleak-retry-connector==3.2.1", "bluetooth-adapters==0.16.1", "bluetooth-auto-recovery==1.2.3", "bluetooth-data-tools==1.11.0", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0952b33978864c..98846c0a9682f7 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.1.3 +bleak-retry-connector==3.2.1 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 8ca4f1aabbdc2c..b9db7f55bdd7cf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -522,7 +522,7 @@ bimmer-connected==0.14.0 bizkaibus==0.1.1 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8d225e3d9ada05..9bee67b6747141 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -443,7 +443,7 @@ bellows==0.36.3 bimmer-connected==0.14.0 # homeassistant.components.bluetooth -bleak-retry-connector==3.1.3 +bleak-retry-connector==3.2.1 # homeassistant.components.bluetooth bleak==0.21.1 From 5f20725fd5c87decb04fc0ccf1ec028b59841ae5 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Thu, 14 Sep 2023 22:32:50 +0200 Subject: [PATCH 528/640] Remove _next_refresh variable in update coordinator (#100323) * Remove _next_refresh variable * Adjust tomorrowio --- homeassistant/components/tomorrowio/__init__.py | 1 - homeassistant/helpers/update_coordinator.py | 12 ++++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/tomorrowio/__init__.py b/homeassistant/components/tomorrowio/__init__.py index 77675e3f2ec48c..626049276f5988 100644 --- a/homeassistant/components/tomorrowio/__init__.py +++ b/homeassistant/components/tomorrowio/__init__.py @@ -221,7 +221,6 @@ async def async_setup_entry(self, entry: ConfigEntry) -> None: await self.async_refresh() self.update_interval = async_set_update_interval(self.hass, self._api) - self._next_refresh = None self._async_unsub_refresh() if self._listeners: self._schedule_refresh() diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 34651fcaf9d1f4..2b570009a57d3e 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -81,7 +81,6 @@ def __init__( self._shutdown_requested = False self.config_entry = config_entries.current_entry.get() self.always_update = always_update - self._next_refresh: float | None = None # It's None before the first successful update. # Components should call async_config_entry_first_refresh @@ -184,7 +183,6 @@ def _unschedule_refresh(self) -> None: """Unschedule any pending refresh since there is no longer any listeners.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None def async_contexts(self) -> Generator[Any, None, None]: """Return all registered contexts.""" @@ -220,13 +218,13 @@ def _schedule_refresh(self) -> None: # We use event.async_call_at because DataUpdateCoordinator does # not need an exact update interval. now = self.hass.loop.time() - if self._next_refresh is None or self._next_refresh <= now: - self._next_refresh = int(now) + self._microsecond - self._next_refresh += self.update_interval.total_seconds() + + next_refresh = int(now) + self._microsecond + next_refresh += self.update_interval.total_seconds() self._unsub_refresh = event.async_call_at( self.hass, self._job, - self._next_refresh, + next_refresh, ) async def _handle_refresh_interval(self, _now: datetime) -> None: @@ -265,7 +263,6 @@ async def async_config_entry_first_refresh(self) -> None: async def async_refresh(self) -> None: """Refresh data and log errors.""" - self._next_refresh = None await self._async_refresh(log_failures=True) async def _async_refresh( # noqa: C901 @@ -405,7 +402,6 @@ def async_set_updated_data(self, data: _DataT) -> None: """Manually update data, notify listeners and reset refresh interval.""" self._async_unsub_refresh() self._debounced_refresh.async_cancel() - self._next_refresh = None self.data = data self.last_update_success = True From 042776ebb82924d39ab706f9f3907967a2730eb5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 17:48:48 -0500 Subject: [PATCH 529/640] Cache entity properties that are never expected to change in the base class (#95315) --- homeassistant/backports/functools.py | 12 +++---- .../components/abode/binary_sensor.py | 4 ++- .../components/binary_sensor/__init__.py | 3 +- homeassistant/components/button/__init__.py | 3 +- homeassistant/components/cover/__init__.py | 3 +- homeassistant/components/date/__init__.py | 3 +- homeassistant/components/datetime/__init__.py | 3 +- homeassistant/components/dsmr/sensor.py | 5 ++- homeassistant/components/event/__init__.py | 3 +- homeassistant/components/filter/sensor.py | 11 +++++-- .../components/group/binary_sensor.py | 3 +- homeassistant/components/group/sensor.py | 5 ++- .../components/here_travel_time/sensor.py | 5 ++- homeassistant/components/huawei_lte/sensor.py | 4 ++- .../components/humidifier/__init__.py | 3 +- .../components/image_processing/__init__.py | 3 +- .../components/integration/sensor.py | 12 +++++-- .../components/media_player/__init__.py | 3 +- .../components/mobile_app/binary_sensor.py | 2 +- homeassistant/components/mobile_app/entity.py | 4 ++- homeassistant/components/mobile_app/sensor.py | 2 +- homeassistant/components/number/__init__.py | 3 +- homeassistant/components/sensor/__init__.py | 3 +- homeassistant/components/statistics/sensor.py | 4 ++- homeassistant/components/switch/__init__.py | 3 +- homeassistant/components/template/weather.py | 4 ++- homeassistant/components/time/__init__.py | 3 +- .../components/unifiprotect/binary_sensor.py | 13 ++++++-- homeassistant/components/update/__init__.py | 3 +- homeassistant/components/zha/binary_sensor.py | 3 +- homeassistant/components/zwave_js/sensor.py | 13 ++++++-- homeassistant/helpers/entity.py | 6 ++-- tests/components/event/test_init.py | 2 ++ tests/components/update/test_init.py | 31 ++++++++++++++++--- tests/helpers/test_entity.py | 7 ++++- 35 files changed, 146 insertions(+), 48 deletions(-) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index 212c8516b4895b..f031004685c867 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -5,18 +5,18 @@ from types import GenericAlias from typing import Any, Generic, Self, TypeVar, overload -_T = TypeVar("_T") +_T_co = TypeVar("_T_co", covariant=True) -class cached_property(Generic[_T]): +class cached_property(Generic[_T_co]): # pylint: disable=invalid-name """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files """ - def __init__(self, func: Callable[[Any], _T]) -> None: + def __init__(self, func: Callable[[Any], _T_co]) -> None: """Initialize.""" - self.func: Callable[[Any], _T] = func + self.func: Callable[[Any], _T_co] = func self.attrname: str | None = None self.__doc__ = func.__doc__ @@ -35,12 +35,12 @@ def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... @overload - def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T_co: ... def __get__( self, instance: Any | None, owner: type[Any] | None = None - ) -> _T | Self: + ) -> _T_co | Self: """Get.""" if instance is None: return self diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index a10dbc8e664839..43f0b8a289ce23 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -50,7 +50,9 @@ def is_on(self) -> bool: """Return True if the binary sensor is on.""" return cast(bool, self._device.is_on) - @property + @property # type: ignore[override] + # We don't know if the class may be set late here + # so we need to override the property to disable the cache. def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" if self._device.get_value("is_window") == "1": diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index 79e20c6f571492..f0b5d6e1d03f48 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -197,7 +198,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 901acdcdec1989..735470033c9e0e 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -96,7 +97,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 354b972e2b78fb..5fae199c961fce 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -11,6 +11,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, @@ -250,7 +251,7 @@ def current_cover_tilt_position(self) -> int | None: """ return self._attr_current_cover_tilt_position - @property + @cached_property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 51f3a492c47a42..9227c45aa98c86 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant, ServiceCall @@ -75,7 +76,7 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b04008672aedf1..c466de922ee4da 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -86,7 +87,7 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @property + @cached_property @final def device_class(self) -> None: """Return entity device class.""" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index e4f9d0e9ab9cd8..642681b43de757 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -592,7 +592,10 @@ def available(self) -> bool: """Entity is only available if there is a telegram.""" return self.telegram is not None - @property + @property # type: ignore[override] + # The device class can change at runtime from GAS to ENERGY + # when new data is received. This should be remembered and restored + # at startup, but the integration currently doesn't support that. def device_class(self) -> SensorDeviceClass | None: """Return the device class of this entity.""" device_class = super().device_class diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfecba..564c77c760420d 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -7,6 +7,7 @@ import logging from typing import Any, Self, final +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -114,7 +115,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @property + @cached_property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index c240d04ec1a766..1b7b3b4bc44e52 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -220,10 +220,17 @@ def __init__( self._state: StateType = None self._filters = filters self._attr_icon = None - self._attr_device_class = None + self._device_class = None self._attr_state_class = None self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} + @property + # This property is not cached because the underlying source may + # not always be available. + def device_class(self) -> SensorDeviceClass | None: # type: ignore[override] + """Return the device class of the sensor.""" + return self._device_class + @callback def _update_filter_sensor_state_event( self, event: EventType[EventStateChangedData] @@ -283,7 +290,7 @@ def _update_filter_sensor_state( self._state = temp_state.state self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) if self._attr_native_unit_of_measurement != new_state.attributes.get( diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index d1e91db8f8667c..f108383caf6696 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -5,6 +5,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, @@ -147,7 +148,7 @@ def async_update_group_state(self) -> None: # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the binary sensor.""" return self._device_class diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 10030ab647fec2..30f0a8d6835a12 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -360,7 +360,10 @@ def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property + @property # type: ignore[override] + # Because the device class is calculated, there is no guarantee that the + # sensors will be available when the entity is created so we do not want to + # cache the value. def device_class(self) -> SensorDeviceClass | None: """Return device class.""" if self._attr_device_class is not None: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 193a86a3d37bcc..737e7f13936b64 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -154,7 +154,10 @@ def _handle_coordinator_update(self) -> None: ) self.async_write_ha_state() - @property + @property # type: ignore[override] + # This property is not cached because the attribute can change + # at run time. This is not expected, but it is currently how + # the HERE integration works. def attribution(self) -> str | None: """Return the attribution.""" if self.coordinator.data is not None: diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 133b569c75159e..450c8d1e54e4eb 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -760,7 +760,9 @@ def icon(self) -> str | None: return self.entity_description.icon_fn(self.state) return self.entity_description.icon - @property + @property # type: ignore[override] + # The device class might change at run time of the signal + # is not a number, so we override here. def device_class(self) -> SensorDeviceClass | None: """Return device class for sensor.""" if self.entity_description.device_class_fn: diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index a525c626f143a5..947dcf2bacc097 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, @@ -158,7 +159,7 @@ def capability_attributes(self) -> dict[str, Any]: return data - @property + @cached_property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index 7640925451ac40..e43778a42c77c5 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,6 +10,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components.camera import Image from homeassistant.const import ( ATTR_ENTITY_ID, @@ -156,7 +157,7 @@ def confidence(self) -> float | None: return self.entity_description.confidence return None - @property + @cached_property def device_class(self) -> ImageProcessingDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 66a99b636816d8..9e7508c1bf1a6b 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -242,6 +242,14 @@ def __init__( self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None self._attr_device_info = device_info + self._device_class: SensorDeviceClass | None = None + + @property # type: ignore[override] + # The underlying source data may be unavailable at startup, so the device + # class may be set late so we need to override the property to disable the cache. + def device_class(self) -> SensorDeviceClass | None: + """Return the device class of the sensor.""" + return self._device_class def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -288,7 +296,7 @@ async def async_added_to_hass(self) -> None: err, ) - self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @callback @@ -319,7 +327,7 @@ def calc_integration(event: EventType[EventStateChangedData]) -> None: and new_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ): - self._attr_device_class = SensorDeviceClass.ENERGY + self._device_class = SensorDeviceClass.ENERGY self._attr_icon = None self.async_write_ha_state() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95b97..fc908fe1098adc 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -22,6 +22,7 @@ import voluptuous as vol from yarl import URL +from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR @@ -495,7 +496,7 @@ class MediaPlayerEntity(Entity): _attr_volume_level: float | None = None # Implement these for your media player - @property + @cached_property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 69ecb913c98942..65155cbe77e1bd 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -67,7 +67,7 @@ def handle_sensor_registration(data): ) -class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): +class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): # type: ignore[misc] """Representation of an mobile app binary sensor.""" @property diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index 120014d1d52a37..bee2ba9674506f 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -69,7 +69,9 @@ def entity_registry_enabled_default(self) -> bool: """Return if entity should be enabled by default.""" return not self._config.get(ATTR_SENSOR_DISABLED) - @property + @property # type: ignore[override,unused-ignore] + # Because the device class is received later from the mobile app + # we do not want to cache the property def device_class(self): """Return the device class.""" return self._config.get(ATTR_SENSOR_DEVICE_CLASS) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index fc325b1b6e97a7..9e00b45d1e3e6f 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -76,7 +76,7 @@ def handle_sensor_registration(data): ) -class MobileAppSensor(MobileAppEntity, RestoreSensor): +class MobileAppSensor(MobileAppEntity, RestoreSensor): # type: ignore[misc] """Representation of an mobile app sensor.""" async def async_restore_last_state(self, last_state): diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index aa3566c5a95d89..ff6926261a6777 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -12,6 +12,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -231,7 +232,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6b4e4a17fc2261..b212e509a90e19 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,6 +11,7 @@ from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -259,7 +260,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @property + @cached_property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index e86a4741080aff..07bccd7522fd6a 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -393,7 +393,9 @@ def _derive_unit_of_measurement(self, new_state: State) -> str | None: unit = base_unit + "/s" return unit - @property + @property # type: ignore[override] + # Since the underlying data source may not be available at startup + # we disable the caching of device_class. def device_class(self) -> SensorDeviceClass | None: """Return the class of this device.""" if self._state_characteristic in STATS_DATETIME: diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index bf3c3424142e04..a443fa783cfe6f 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, @@ -102,7 +103,7 @@ class SwitchEntity(ToggleEntity): entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @property + @cached_property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index a04fc7a641df3c..128b35dffb2277 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -294,7 +294,9 @@ async def async_forecast_twice_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" return self._forecast_twice_daily - @property + @property # type: ignore[override] + # Because attribution is a template, it can change at any time + # and we don't want to cache it. def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 26d40191fb959b..6f83551488080b 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -8,6 +8,7 @@ import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall @@ -75,7 +76,7 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @property + @cached_property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 668fe479e1f15d..10aad4625ec709 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -552,6 +552,7 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): device: Camera | Light | Sensor entity_description: ProtectBinaryEntityDescription + _device_class: BinarySensorDeviceClass | None @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -561,9 +562,17 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( - updated_device.mount_type, BinarySensorDeviceClass.DOOR + self._device_class = MOUNT_DEVICE_CLASS_MAP.get( + self.device.mount_type, BinarySensorDeviceClass.DOOR ) + else: + self._device_class = self.entity_description.device_class + + @property # type: ignore[override] + # UFP smart sensors can change device class at runtime + def device_class(self) -> BinarySensorDeviceClass | None: + """Return the class of this sensor.""" + return self._device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e23032e24fe75d..e27a9b8e422ffc 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -11,6 +11,7 @@ from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol +from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory @@ -223,7 +224,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @property + @cached_property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index c32bd5eeb67a74..64d7c8ddb3d1c0 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,6 +8,7 @@ from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone +from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -195,7 +196,7 @@ def name(self) -> str | None: zone_type = self._cluster_handler.cluster.get("zone_type") return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") - @property + @cached_property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" zone_type = self._cluster_handler.cluster.get("zone_type") diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6965..3ec91d6647b862 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -645,6 +645,13 @@ def native_unit_of_measurement(self) -> str | None: return None return str(self.info.primary_value.metadata.unit) + @property # type: ignore[override] + # fget is used in the child classes which is not compatible with cached_property + # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 + def device_class(self) -> SensorDeviceClass | None: + """Return device class of sensor.""" + return super().device_class + class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @@ -737,7 +744,9 @@ def options(self) -> list[str] | None: return list(self.info.primary_value.metadata.states.values()) return None - @property + @property # type: ignore[override] + # fget is used which is not compatible with cached_property + # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" if (device_class := super().device_class) is not None: @@ -781,7 +790,7 @@ def __init__( additional_info=[property_key_name] if property_key_name else None, ) - @property + @property # type: ignore[override] def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 5ed16408388545..ac43e2de956e46 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -550,7 +550,7 @@ def device_info(self) -> DeviceInfo | None: """ return self._attr_device_info - @property + @cached_property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -639,7 +639,7 @@ def entity_registry_visible_default(self) -> bool: return self.entity_description.entity_registry_visible_default return True - @property + @cached_property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution @@ -653,7 +653,7 @@ def entity_category(self) -> EntityCategory | None: return self.entity_description.entity_category return None - @property + @cached_property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 66cda6a088a3bb..7e00180f1fcfa0 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -51,6 +51,7 @@ async def test_event() -> None: event.event_types # Test retrieving data from entity description + del event.device_class event.entity_description = EventEntityDescription( key="test_event", event_types=["short_press", "long_press"], @@ -63,6 +64,7 @@ async def test_event() -> None: event._attr_event_types = ["short_press", "long_press", "double_press"] assert event.event_types == ["short_press", "long_press", "double_press"] event._attr_device_class = EventDeviceClass.BUTTON + del event.device_class assert event.device_class == EventDeviceClass.BUTTON # Test triggering an event diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 73f98c9e2db06c..68bd62dabfef89 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -59,11 +59,13 @@ class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" -async def test_update(hass: HomeAssistant) -> None: - """Test getting data from the mocked update entity.""" +def _create_mock_update_entity( + hass: HomeAssistant, +) -> MockUpdateEntity: + mock_platform = MockEntityPlatform(hass) update = MockUpdateEntity() update.hass = hass - update.platform = MockEntityPlatform(hass) + update.platform = mock_platform update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.1" @@ -71,6 +73,13 @@ async def test_update(hass: HomeAssistant) -> None: update._attr_release_url = "https://example.com" update._attr_title = "Title" + return update + + +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" + update = _create_mock_update_entity(hass) + assert update.entity_category is EntityCategory.DIAGNOSTIC assert ( update.entity_picture @@ -93,7 +102,6 @@ async def test_update(hass: HomeAssistant) -> None: ATTR_SKIPPED_VERSION: None, ATTR_TITLE: "Title", } - # Test no update available update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.0" @@ -120,14 +128,19 @@ async def test_update(hass: HomeAssistant) -> None: assert update.state is STATE_ON # Test entity category becomes config when its possible to install + update = _create_mock_update_entity(hass) update._attr_supported_features = UpdateEntityFeature.INSTALL assert update.entity_category is EntityCategory.CONFIG # UpdateEntityDescription was set + update = _create_mock_update_entity(hass) update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -137,14 +150,24 @@ async def test_update(hass: HomeAssistant) -> None: assert update.entity_category is None # Device class via attribute (override entity description) + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_device_class = None assert update.device_class is None + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_device_class = UpdateDeviceClass.FIRMWARE assert update.device_class is UpdateDeviceClass.FIRMWARE # Entity Attribute via attribute (override entity description) + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_entity_category = None assert update.entity_category is None + + update = _create_mock_update_entity(hass) + update._attr_supported_features = 0 update._attr_entity_category = EntityCategory.DIAGNOSTIC assert update.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 61ee38a66a7a96..2961210f5eca4c 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -98,9 +98,13 @@ class TestHelpersEntity: def setup_method(self, method): """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self._create_entity() + + def _create_entity(self) -> None: self.entity = entity.Entity() self.entity.entity_id = "test.overwrite_hidden_true" - self.hass = self.entity.hass = get_test_home_assistant() + self.entity.hass = self.hass self.entity.schedule_update_ha_state() self.hass.block_till_done() @@ -123,6 +127,7 @@ def test_device_class(self): with patch( "homeassistant.helpers.entity.Entity.device_class", new="test_class" ): + self._create_entity() self.entity.schedule_update_ha_state() self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) From 6a9c9ca73531542fdf160b231368cd616720b472 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 17:55:56 -0500 Subject: [PATCH 530/640] Improve performance of mqtt_room (#100408) --- homeassistant/components/mqtt_room/sensor.py | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt_room/sensor.py b/homeassistant/components/mqtt_room/sensor.py index 1b4cdb1c58317d..4eb3a3f5171794 100644 --- a/homeassistant/components/mqtt_room/sensor.py +++ b/homeassistant/components/mqtt_room/sensor.py @@ -2,8 +2,9 @@ from __future__ import annotations from datetime import timedelta -import json +from functools import lru_cache import logging +from typing import Any import voluptuous as vol @@ -24,6 +25,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util, slugify +from homeassistant.util.json import json_loads _LOGGER = logging.getLogger(__name__) @@ -47,9 +49,16 @@ } ).extend(mqtt.config.MQTT_RO_SCHEMA.schema) + +@lru_cache(maxsize=256) +def _slugify_upper(string: str) -> str: + """Return a slugified version of string, uppercased.""" + return slugify(string).upper() + + MQTT_PAYLOAD = vol.Schema( vol.All( - json.loads, + json_loads, vol.Schema( { vol.Required(ATTR_ID): cv.string, @@ -106,7 +115,7 @@ def __init__( self._state = STATE_NOT_HOME self._name = name self._state_topic = f"{state_topic}/+" - self._device_id = slugify(device_id).upper() + self._device_id = _slugify_upper(device_id) self._timeout = timeout self._consider_home = ( timedelta(seconds=consider_home) if consider_home else None @@ -179,11 +188,10 @@ def update(self) -> None: self._state = STATE_NOT_HOME -def _parse_update_data(topic, data): +def _parse_update_data(topic: str, data: dict[str, Any]) -> dict[str, Any]: """Parse the room presence update.""" parts = topic.split("/") room = parts[-1] - device_id = slugify(data.get(ATTR_ID)).upper() + device_id = _slugify_upper(data.get(ATTR_ID)) distance = data.get("distance") - parsed_data = {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} - return parsed_data + return {ATTR_DEVICE_ID: device_id, ATTR_ROOM: room, ATTR_DISTANCE: distance} From b68ceb3ce4ae61127d5200aabd9dda40eb687465 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 14 Sep 2023 21:28:59 -0500 Subject: [PATCH 531/640] Use more shorthand attributes in hyperion (#100213) * Use more shorthand attributes in hyperion There are likely some more here, but I only did the safe ones * Update homeassistant/components/hyperion/switch.py Co-authored-by: Joost Lekkerkerker * Apply suggestions from code review --------- Co-authored-by: Joost Lekkerkerker --- homeassistant/components/hyperion/camera.py | 27 ++++++---------- homeassistant/components/hyperion/light.py | 30 +++++------------- homeassistant/components/hyperion/switch.py | 35 +++++++-------------- 3 files changed, 28 insertions(+), 64 deletions(-) diff --git a/homeassistant/components/hyperion/camera.py b/homeassistant/components/hyperion/camera.py index 9c9e509947db80..23ce27151404b8 100644 --- a/homeassistant/components/hyperion/camera.py +++ b/homeassistant/components/hyperion/camera.py @@ -119,7 +119,7 @@ def __init__( """Initialize the switch.""" super().__init__() - self._unique_id = get_hyperion_unique_id( + self._attr_unique_id = get_hyperion_unique_id( server_id, instance_num, TYPE_HYPERION_CAMERA ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -135,11 +135,13 @@ def __init__( self._client_callbacks = { f"{KEY_LEDCOLORS}-{KEY_IMAGE_STREAM}-{KEY_UPDATE}": self._update_imagestream } - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=instance_name, + configuration_url=hyperion_client.remote_url, + ) @property def is_on(self) -> bool: @@ -231,7 +233,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) @@ -242,17 +244,6 @@ async def async_will_remove_from_hass(self) -> None: """Cleanup prior to hass removal.""" self._client.remove_callbacks(self._client_callbacks) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - CAMERA_TYPES = { TYPE_HYPERION_CAMERA: HyperionCamera, diff --git a/homeassistant/components/hyperion/light.py b/homeassistant/components/hyperion/light.py index 105e577efad7f8..824d83591efa5a 100644 --- a/homeassistant/components/hyperion/light.py +++ b/homeassistant/components/hyperion/light.py @@ -132,7 +132,7 @@ def __init__( hyperion_client: client.HyperionClient, ) -> None: """Initialize the light.""" - self._unique_id = self._compute_unique_id(server_id, instance_num) + self._attr_unique_id = self._compute_unique_id(server_id, instance_num) self._device_id = get_hyperion_device_id(server_id, instance_num) self._instance_name = instance_name self._options = options @@ -153,16 +153,18 @@ def __init__( f"{const.KEY_PRIORITIES}-{const.KEY_UPDATE}": self._update_priorities, f"{const.KEY_CLIENT}-{const.KEY_UPDATE}": self._update_client, } + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) def _compute_unique_id(self, server_id: str, instance_num: int) -> str: """Compute a unique id for this instance.""" return get_hyperion_unique_id(server_id, instance_num, TYPE_HYPERION_LIGHT) - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - return True - @property def brightness(self) -> int: """Return the brightness of this light between 0..255.""" @@ -196,22 +198,6 @@ def available(self) -> bool: """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id - - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - def _get_option(self, key: str) -> Any: """Get a value from the provided options.""" defaults = { diff --git a/homeassistant/components/hyperion/switch.py b/homeassistant/components/hyperion/switch.py index 11e1dc199be6cf..eb7b260a3700c9 100644 --- a/homeassistant/components/hyperion/switch.py +++ b/homeassistant/components/hyperion/switch.py @@ -133,6 +133,8 @@ class HyperionComponentSwitch(SwitchEntity): _attr_entity_category = EntityCategory.CONFIG _attr_should_poll = False _attr_has_entity_name = True + # These component controls are for advanced users and are disabled by default. + _attr_entity_registry_enabled_default = False def __init__( self, @@ -143,7 +145,7 @@ def __init__( hyperion_client: client.HyperionClient, ) -> None: """Initialize the switch.""" - self._unique_id = _component_to_unique_id( + self._attr_unique_id = _component_to_unique_id( server_id, component_name, instance_num ) self._device_id = get_hyperion_device_id(server_id, instance_num) @@ -154,17 +156,13 @@ def __init__( self._client_callbacks = { f"{KEY_COMPONENTS}-{KEY_UPDATE}": self._update_components } - - @property - def entity_registry_enabled_default(self) -> bool: - """Whether or not the entity is enabled by default.""" - # These component controls are for advanced users and are disabled by default. - return False - - @property - def unique_id(self) -> str: - """Return a unique id for this instance.""" - return self._unique_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self._device_id)}, + manufacturer=HYPERION_MANUFACTURER_NAME, + model=HYPERION_MODEL_NAME, + name=self._instance_name, + configuration_url=self._client.remote_url, + ) @property def is_on(self) -> bool: @@ -179,17 +177,6 @@ def available(self) -> bool: """Return server availability.""" return bool(self._client.has_loaded_state) - @property - def device_info(self) -> DeviceInfo: - """Return device information.""" - return DeviceInfo( - identifiers={(DOMAIN, self._device_id)}, - manufacturer=HYPERION_MANUFACTURER_NAME, - model=HYPERION_MODEL_NAME, - name=self._instance_name, - configuration_url=self._client.remote_url, - ) - async def _async_send_set_component(self, value: bool) -> None: """Send a component control request.""" await self._client.async_send_set_component( @@ -219,7 +206,7 @@ async def async_added_to_hass(self) -> None: self.async_on_remove( async_dispatcher_connect( self.hass, - SIGNAL_ENTITY_REMOVE.format(self._unique_id), + SIGNAL_ENTITY_REMOVE.format(self._attr_unique_id), functools.partial(self.async_remove, force_remove=True), ) ) From 772ac9766bb9a247e6ac19bc0481611005870943 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 15 Sep 2023 07:52:29 +0200 Subject: [PATCH 532/640] Move awair coordinators to their own file (#100411) * Move awair coordinators to their file * Add awair/coordinator.py to .coveragerc --- .coveragerc | 1 + homeassistant/components/awair/__init__.py | 117 ++---------------- homeassistant/components/awair/coordinator.py | 116 +++++++++++++++++ homeassistant/components/awair/sensor.py | 2 +- 4 files changed, 125 insertions(+), 111 deletions(-) create mode 100644 homeassistant/components/awair/coordinator.py diff --git a/.coveragerc b/.coveragerc index 015d1c541e93a0..2f43fe3ab3e5b8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -101,6 +101,7 @@ omit = homeassistant/components/azure_devops/__init__.py homeassistant/components/azure_devops/sensor.py homeassistant/components/azure_service_bus/* + homeassistant/components/awair/coordinator.py homeassistant/components/baf/__init__.py homeassistant/components/baf/climate.py homeassistant/components/baf/entity.py diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index 083c7d48b037c1..cb974707e9389a 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1,29 +1,16 @@ """The awair component.""" from __future__ import annotations -from asyncio import gather, timeout -from dataclasses import dataclass -from datetime import timedelta - -from aiohttp import ClientSession -from python_awair import Awair, AwairLocal -from python_awair.air_data import AirData -from python_awair.devices import AwairBaseDevice, AwairLocalDevice -from python_awair.exceptions import AuthError, AwairError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST, Platform +from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import ( - API_TIMEOUT, - DOMAIN, - LOGGER, - UPDATE_INTERVAL_CLOUD, - UPDATE_INTERVAL_LOCAL, + +from .const import DOMAIN +from .coordinator import ( + AwairCloudDataUpdateCoordinator, + AwairDataUpdateCoordinator, + AwairLocalDataUpdateCoordinator, ) PLATFORMS = [Platform.SENSOR] @@ -70,93 +57,3 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> hass.data[DOMAIN].pop(config_entry.entry_id) return unload_ok - - -@dataclass -class AwairResult: - """Wrapper class to hold an awair device and set of air data.""" - - device: AwairBaseDevice - air_data: AirData - - -class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): - """Define a wrapper class to update Awair data.""" - - def __init__( - self, - hass: HomeAssistant, - config_entry: ConfigEntry, - update_interval: timedelta | None, - ) -> None: - """Set up the AwairDataUpdateCoordinator class.""" - self._config_entry = config_entry - self.title = config_entry.title - - super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) - - async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: - """Fetch latest air quality data.""" - LOGGER.debug("Fetching data for %s", device.uuid) - air_data = await device.air_data_latest() - LOGGER.debug(air_data) - return AwairResult(device=device, air_data=air_data) - - -class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from Cloud API.""" - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairCloudDataUpdateCoordinator class.""" - access_token = config_entry.data[CONF_ACCESS_TOKEN] - self._awair = Awair(access_token=access_token, session=session) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - LOGGER.debug("Fetching users and devices") - user = await self._awair.user() - devices = await user.devices() - results = await gather( - *(self._fetch_air_data(device) for device in devices) - ) - return {result.device.uuid: result for result in results} - except AuthError as err: - raise ConfigEntryAuthFailed from err - except Exception as err: - raise UpdateFailed(err) from err - - -class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): - """Define a wrapper class to update Awair data from the local API.""" - - _device: AwairLocalDevice | None = None - - def __init__( - self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession - ) -> None: - """Set up the AwairLocalDataUpdateCoordinator class.""" - self._awair = AwairLocal( - session=session, device_addrs=[config_entry.data[CONF_HOST]] - ) - - super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) - - async def _async_update_data(self) -> dict[str, AwairResult]: - """Update data via Awair client library.""" - async with timeout(API_TIMEOUT): - try: - if self._device is None: - LOGGER.debug("Fetching devices") - devices = await self._awair.devices() - self._device = devices[0] - result = await self._fetch_air_data(self._device) - return {result.device.uuid: result} - except AwairError as err: - LOGGER.error("Unexpected API error: %s", err) - raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/coordinator.py b/homeassistant/components/awair/coordinator.py new file mode 100644 index 00000000000000..b687a916a2de9b --- /dev/null +++ b/homeassistant/components/awair/coordinator.py @@ -0,0 +1,116 @@ +"""DataUpdateCoordinators for awair integration.""" +from __future__ import annotations + +from asyncio import gather, timeout +from dataclasses import dataclass +from datetime import timedelta + +from aiohttp import ClientSession +from python_awair import Awair, AwairLocal +from python_awair.air_data import AirData +from python_awair.devices import AwairBaseDevice, AwairLocalDevice +from python_awair.exceptions import AuthError, AwairError + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + API_TIMEOUT, + DOMAIN, + LOGGER, + UPDATE_INTERVAL_CLOUD, + UPDATE_INTERVAL_LOCAL, +) + + +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairBaseDevice + air_data: AirData + + +class AwairDataUpdateCoordinator(DataUpdateCoordinator[dict[str, AwairResult]]): + """Define a wrapper class to update Awair data.""" + + def __init__( + self, + hass: HomeAssistant, + config_entry: ConfigEntry, + update_interval: timedelta | None, + ) -> None: + """Set up the AwairDataUpdateCoordinator class.""" + self._config_entry = config_entry + self.title = config_entry.title + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=update_interval) + + async def _fetch_air_data(self, device: AwairBaseDevice) -> AwairResult: + """Fetch latest air quality data.""" + LOGGER.debug("Fetching data for %s", device.uuid) + air_data = await device.air_data_latest() + LOGGER.debug(air_data) + return AwairResult(device=device, air_data=air_data) + + +class AwairCloudDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from Cloud API.""" + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairCloudDataUpdateCoordinator class.""" + access_token = config_entry.data[CONF_ACCESS_TOKEN] + self._awair = Awair(access_token=access_token, session=session) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_CLOUD) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + LOGGER.debug("Fetching users and devices") + user = await self._awair.user() + devices = await user.devices() + results = await gather( + *(self._fetch_air_data(device) for device in devices) + ) + return {result.device.uuid: result for result in results} + except AuthError as err: + raise ConfigEntryAuthFailed from err + except Exception as err: + raise UpdateFailed(err) from err + + +class AwairLocalDataUpdateCoordinator(AwairDataUpdateCoordinator): + """Define a wrapper class to update Awair data from the local API.""" + + _device: AwairLocalDevice | None = None + + def __init__( + self, hass: HomeAssistant, config_entry: ConfigEntry, session: ClientSession + ) -> None: + """Set up the AwairLocalDataUpdateCoordinator class.""" + self._awair = AwairLocal( + session=session, device_addrs=[config_entry.data[CONF_HOST]] + ) + + super().__init__(hass, config_entry, UPDATE_INTERVAL_LOCAL) + + async def _async_update_data(self) -> dict[str, AwairResult]: + """Update data via Awair client library.""" + async with timeout(API_TIMEOUT): + try: + if self._device is None: + LOGGER.debug("Fetching devices") + devices = await self._awair.devices() + self._device = devices[0] + result = await self._fetch_air_data(self._device) + return {result.device.uuid: result} + except AwairError as err: + LOGGER.error("Unexpected API error: %s", err) + raise UpdateFailed(err) from err diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 279621673309d0..2a09a8d4e70378 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -31,7 +31,6 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import AwairDataUpdateCoordinator, AwairResult from .const import ( API_CO2, API_DUST, @@ -46,6 +45,7 @@ ATTRIBUTION, DOMAIN, ) +from .coordinator import AwairDataUpdateCoordinator, AwairResult DUST_ALIASES = [API_PM25, API_PM10] From 9470c71d49747502c1cdfbeb12d2833dafd455de Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Fri, 15 Sep 2023 06:52:50 +0100 Subject: [PATCH 533/640] Fix current condition in IPMA (#100412) always use hourly forecast to retrieve current weather condition. fix #100393 --- homeassistant/components/ipma/weather.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/ipma/weather.py b/homeassistant/components/ipma/weather.py index a5bb398157582e..f9b93cbe954142 100644 --- a/homeassistant/components/ipma/weather.py +++ b/homeassistant/components/ipma/weather.py @@ -103,10 +103,7 @@ async def async_update(self) -> None: else: self._daily_forecast = None - if self._period == 1 or self._forecast_listeners["hourly"]: - await self._update_forecast("hourly", 1, True) - else: - self._hourly_forecast = None + await self._update_forecast("hourly", 1, True) _LOGGER.debug( "Updated location %s based on %s, current observation %s", @@ -139,8 +136,8 @@ def _condition_conversion(self, identifier, forecast_dt): @property def condition(self): - """Return the current condition.""" - forecast = self._hourly_forecast or self._daily_forecast + """Return the current condition which is only available on the hourly forecast data.""" + forecast = self._hourly_forecast if not forecast: return From a70235046aba7181eaa97023254e72e8c3551fc5 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Sep 2023 08:07:27 +0200 Subject: [PATCH 534/640] Tweak datetime service schema (#100380) --- homeassistant/components/datetime/__init__.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index c466de922ee4da..b17a8d65250904 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -13,7 +13,6 @@ from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv from homeassistant.helpers.config_validation import ( # noqa: F401 - ENTITY_SERVICE_FIELDS, PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, ) @@ -54,7 +53,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: SERVICE_SET_VALUE, { vol.Required(ATTR_DATETIME): cv.datetime, - **ENTITY_SERVICE_FIELDS, }, _async_set_value, ) From a8013836e10e89e2fc1f30a2cf6ffb13faf118d7 Mon Sep 17 00:00:00 2001 From: TJ Horner Date: Thu, 14 Sep 2023 23:28:27 -0700 Subject: [PATCH 535/640] Bump apple_weatherkit to 1.0.3 (#100416) --- 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 1e8bb8ba5c5bc1..34a5d45ca1f3e5 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.2"] + "requirements": ["apple_weatherkit==1.0.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index b9db7f55bdd7cf..0304faa1f08b4a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -424,7 +424,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.2 +apple_weatherkit==1.0.3 # homeassistant.components.apprise apprise==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9bee67b6747141..6063842f5fc8fb 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -390,7 +390,7 @@ anthemav==1.4.1 apcaccess==0.0.13 # homeassistant.components.weatherkit -apple_weatherkit==1.0.2 +apple_weatherkit==1.0.3 # homeassistant.components.apprise apprise==1.5.0 From 7723a9b36b547e9851b4181dfb3799ed324fcaaa Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Fri, 15 Sep 2023 10:04:41 +0200 Subject: [PATCH 536/640] Move airtouch4 coordinator to its own file (#100424) --- .coveragerc | 1 + .../components/airtouch4/__init__.py | 43 +---------------- .../components/airtouch4/coordinator.py | 46 +++++++++++++++++++ 3 files changed, 48 insertions(+), 42 deletions(-) create mode 100644 homeassistant/components/airtouch4/coordinator.py diff --git a/.coveragerc b/.coveragerc index 2f43fe3ab3e5b8..ddde800cd77ea5 100644 --- a/.coveragerc +++ b/.coveragerc @@ -45,6 +45,7 @@ omit = homeassistant/components/airthings_ble/sensor.py homeassistant/components/airtouch4/__init__.py homeassistant/components/airtouch4/climate.py + homeassistant/components/airtouch4/coordinator.py homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual_pro/__init__.py diff --git a/homeassistant/components/airtouch4/__init__.py b/homeassistant/components/airtouch4/__init__.py index a2c3f716ab1bb9..dc5172096a728d 100644 --- a/homeassistant/components/airtouch4/__init__.py +++ b/homeassistant/components/airtouch4/__init__.py @@ -1,19 +1,13 @@ """The AirTouch4 integration.""" -import logging - from airtouch4pyapi import AirTouch -from airtouch4pyapi.airtouch import AirTouchStatus -from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import AirtouchDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE] @@ -44,38 +38,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Airtouch data.""" - - def __init__(self, hass, airtouch): - """Initialize global Airtouch data updater.""" - self.airtouch = airtouch - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Airtouch.""" - await self.airtouch.UpdateInfo() - if self.airtouch.Status != AirTouchStatus.OK: - raise UpdateFailed("Airtouch connection issue") - return { - "acs": [ - {"ac_number": ac.AcNumber, "is_on": ac.IsOn} - for ac in self.airtouch.GetAcs() - ], - "groups": [ - { - "group_number": group.GroupNumber, - "group_name": group.GroupName, - "is_on": group.IsOn, - } - for group in self.airtouch.GetGroups() - ], - } diff --git a/homeassistant/components/airtouch4/coordinator.py b/homeassistant/components/airtouch4/coordinator.py new file mode 100644 index 00000000000000..e78bf62dbd0ec2 --- /dev/null +++ b/homeassistant/components/airtouch4/coordinator.py @@ -0,0 +1,46 @@ +"""DataUpdateCoordinator for the airtouch integration.""" +import logging + +from airtouch4pyapi.airtouch import AirTouchStatus + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class AirtouchDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Airtouch data.""" + + def __init__(self, hass, airtouch): + """Initialize global Airtouch data updater.""" + self.airtouch = airtouch + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Airtouch.""" + await self.airtouch.UpdateInfo() + if self.airtouch.Status != AirTouchStatus.OK: + raise UpdateFailed("Airtouch connection issue") + return { + "acs": [ + {"ac_number": ac.AcNumber, "is_on": ac.IsOn} + for ac in self.airtouch.GetAcs() + ], + "groups": [ + { + "group_number": group.GroupNumber, + "group_name": group.GroupName, + "is_on": group.IsOn, + } + for group in self.airtouch.GetGroups() + ], + } From d1afcd773faa05f26cd9a2a7da64b5dda6b24016 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Fri, 15 Sep 2023 11:25:24 +0200 Subject: [PATCH 537/640] Revert "Cache entity properties that are never expected to change in the base class" (#100422) Revert "Cache entity properties that are never expected to change in the base class (#95315)" This reverts commit 042776ebb82924d39ab706f9f3907967a2730eb5. --- homeassistant/backports/functools.py | 12 +++---- .../components/abode/binary_sensor.py | 4 +-- .../components/binary_sensor/__init__.py | 3 +- homeassistant/components/button/__init__.py | 3 +- homeassistant/components/cover/__init__.py | 3 +- homeassistant/components/date/__init__.py | 3 +- homeassistant/components/datetime/__init__.py | 3 +- homeassistant/components/dsmr/sensor.py | 5 +-- homeassistant/components/event/__init__.py | 3 +- homeassistant/components/filter/sensor.py | 11 ++----- .../components/group/binary_sensor.py | 3 +- homeassistant/components/group/sensor.py | 5 +-- .../components/here_travel_time/sensor.py | 5 +-- homeassistant/components/huawei_lte/sensor.py | 4 +-- .../components/humidifier/__init__.py | 3 +- .../components/image_processing/__init__.py | 3 +- .../components/integration/sensor.py | 12 ++----- .../components/media_player/__init__.py | 3 +- .../components/mobile_app/binary_sensor.py | 2 +- homeassistant/components/mobile_app/entity.py | 4 +-- homeassistant/components/mobile_app/sensor.py | 2 +- homeassistant/components/number/__init__.py | 3 +- homeassistant/components/sensor/__init__.py | 3 +- homeassistant/components/statistics/sensor.py | 4 +-- homeassistant/components/switch/__init__.py | 3 +- homeassistant/components/template/weather.py | 4 +-- homeassistant/components/time/__init__.py | 3 +- .../components/unifiprotect/binary_sensor.py | 13 ++------ homeassistant/components/update/__init__.py | 3 +- homeassistant/components/zha/binary_sensor.py | 3 +- homeassistant/components/zwave_js/sensor.py | 13 ++------ homeassistant/helpers/entity.py | 6 ++-- tests/components/event/test_init.py | 2 -- tests/components/update/test_init.py | 31 +++---------------- tests/helpers/test_entity.py | 7 +---- 35 files changed, 48 insertions(+), 146 deletions(-) diff --git a/homeassistant/backports/functools.py b/homeassistant/backports/functools.py index f031004685c867..212c8516b4895b 100644 --- a/homeassistant/backports/functools.py +++ b/homeassistant/backports/functools.py @@ -5,18 +5,18 @@ from types import GenericAlias from typing import Any, Generic, Self, TypeVar, overload -_T_co = TypeVar("_T_co", covariant=True) +_T = TypeVar("_T") -class cached_property(Generic[_T_co]): # pylint: disable=invalid-name +class cached_property(Generic[_T]): """Backport of Python 3.12's cached_property. Includes https://github.com/python/cpython/pull/101890/files """ - def __init__(self, func: Callable[[Any], _T_co]) -> None: + def __init__(self, func: Callable[[Any], _T]) -> None: """Initialize.""" - self.func: Callable[[Any], _T_co] = func + self.func: Callable[[Any], _T] = func self.attrname: str | None = None self.__doc__ = func.__doc__ @@ -35,12 +35,12 @@ def __get__(self, instance: None, owner: type[Any] | None = None) -> Self: ... @overload - def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T_co: + def __get__(self, instance: Any, owner: type[Any] | None = None) -> _T: ... def __get__( self, instance: Any | None, owner: type[Any] | None = None - ) -> _T_co | Self: + ) -> _T | Self: """Get.""" if instance is None: return self diff --git a/homeassistant/components/abode/binary_sensor.py b/homeassistant/components/abode/binary_sensor.py index 43f0b8a289ce23..a10dbc8e664839 100644 --- a/homeassistant/components/abode/binary_sensor.py +++ b/homeassistant/components/abode/binary_sensor.py @@ -50,9 +50,7 @@ def is_on(self) -> bool: """Return True if the binary sensor is on.""" return cast(bool, self._device.is_on) - @property # type: ignore[override] - # We don't know if the class may be set late here - # so we need to override the property to disable the cache. + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of the binary sensor.""" if self._device.get_value("is_window") == "1": diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index f0b5d6e1d03f48..79e20c6f571492 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -9,7 +9,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.core import HomeAssistant @@ -198,7 +197,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/button/__init__.py b/homeassistant/components/button/__init__.py index 735470033c9e0e..901acdcdec1989 100644 --- a/homeassistant/components/button/__init__.py +++ b/homeassistant/components/button/__init__.py @@ -9,7 +9,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -97,7 +96,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @cached_property + @property def device_class(self) -> ButtonDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index 5fae199c961fce..354b972e2b78fb 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -11,7 +11,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_CLOSE_COVER, @@ -251,7 +250,7 @@ def current_cover_tilt_position(self) -> int | None: """ return self._attr_current_cover_tilt_position - @cached_property + @property def device_class(self) -> CoverDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/date/__init__.py b/homeassistant/components/date/__init__.py index 9227c45aa98c86..51f3a492c47a42 100644 --- a/homeassistant/components/date/__init__.py +++ b/homeassistant/components/date/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_DATE from homeassistant.core import HomeAssistant, ServiceCall @@ -76,7 +75,7 @@ class DateEntity(Entity): _attr_native_value: date | None _attr_state: None = None - @cached_property + @property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/datetime/__init__.py b/homeassistant/components/datetime/__init__.py index b17a8d65250904..e25f4535d0c1a1 100644 --- a/homeassistant/components/datetime/__init__.py +++ b/homeassistant/components/datetime/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.helpers import config_validation as cv @@ -85,7 +84,7 @@ class DateTimeEntity(Entity): _attr_state: None = None _attr_native_value: datetime | None - @cached_property + @property @final def device_class(self) -> None: """Return entity device class.""" diff --git a/homeassistant/components/dsmr/sensor.py b/homeassistant/components/dsmr/sensor.py index 642681b43de757..e4f9d0e9ab9cd8 100644 --- a/homeassistant/components/dsmr/sensor.py +++ b/homeassistant/components/dsmr/sensor.py @@ -592,10 +592,7 @@ def available(self) -> bool: """Entity is only available if there is a telegram.""" return self.telegram is not None - @property # type: ignore[override] - # The device class can change at runtime from GAS to ENERGY - # when new data is received. This should be remembered and restored - # at startup, but the integration currently doesn't support that. + @property def device_class(self) -> SensorDeviceClass | None: """Return the device class of this entity.""" device_class = super().device_class diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index 564c77c760420d..f6ba2d79bfecba 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -7,7 +7,6 @@ import logging from typing import Any, Self, final -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.config_validation import ( # noqa: F401 @@ -115,7 +114,7 @@ class EventEntity(RestoreEntity): __last_event_type: str | None = None __last_event_attributes: dict[str, Any] | None = None - @cached_property + @property def device_class(self) -> EventDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/filter/sensor.py b/homeassistant/components/filter/sensor.py index 1b7b3b4bc44e52..c240d04ec1a766 100644 --- a/homeassistant/components/filter/sensor.py +++ b/homeassistant/components/filter/sensor.py @@ -220,17 +220,10 @@ def __init__( self._state: StateType = None self._filters = filters self._attr_icon = None - self._device_class = None + self._attr_device_class = None self._attr_state_class = None self._attr_extra_state_attributes = {ATTR_ENTITY_ID: entity_id} - @property - # This property is not cached because the underlying source may - # not always be available. - def device_class(self) -> SensorDeviceClass | None: # type: ignore[override] - """Return the device class of the sensor.""" - return self._device_class - @callback def _update_filter_sensor_state_event( self, event: EventType[EventStateChangedData] @@ -290,7 +283,7 @@ def _update_filter_sensor_state( self._state = temp_state.state self._attr_icon = new_state.attributes.get(ATTR_ICON, ICON) - self._device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = new_state.attributes.get(ATTR_DEVICE_CLASS) self._attr_state_class = new_state.attributes.get(ATTR_STATE_CLASS) if self._attr_native_unit_of_measurement != new_state.attributes.get( diff --git a/homeassistant/components/group/binary_sensor.py b/homeassistant/components/group/binary_sensor.py index f108383caf6696..d1e91db8f8667c 100644 --- a/homeassistant/components/group/binary_sensor.py +++ b/homeassistant/components/group/binary_sensor.py @@ -5,7 +5,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( DEVICE_CLASSES_SCHEMA, DOMAIN as BINARY_SENSOR_DOMAIN, @@ -148,7 +147,7 @@ def async_update_group_state(self) -> None: # Set as ON if any / all member is ON self._attr_is_on = self.mode(state == STATE_ON for state in states) - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return the sensor class of the binary sensor.""" return self._device_class diff --git a/homeassistant/components/group/sensor.py b/homeassistant/components/group/sensor.py index 30f0a8d6835a12..10030ab647fec2 100644 --- a/homeassistant/components/group/sensor.py +++ b/homeassistant/components/group/sensor.py @@ -360,10 +360,7 @@ def extra_state_attributes(self) -> dict[str, Any]: """Return the state attributes of the sensor.""" return {ATTR_ENTITY_ID: self._entity_ids, **self._extra_state_attribute} - @property # type: ignore[override] - # Because the device class is calculated, there is no guarantee that the - # sensors will be available when the entity is created so we do not want to - # cache the value. + @property def device_class(self) -> SensorDeviceClass | None: """Return device class.""" if self._attr_device_class is not None: diff --git a/homeassistant/components/here_travel_time/sensor.py b/homeassistant/components/here_travel_time/sensor.py index 737e7f13936b64..193a86a3d37bcc 100644 --- a/homeassistant/components/here_travel_time/sensor.py +++ b/homeassistant/components/here_travel_time/sensor.py @@ -154,10 +154,7 @@ def _handle_coordinator_update(self) -> None: ) self.async_write_ha_state() - @property # type: ignore[override] - # This property is not cached because the attribute can change - # at run time. This is not expected, but it is currently how - # the HERE integration works. + @property def attribution(self) -> str | None: """Return the attribution.""" if self.coordinator.data is not None: diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 450c8d1e54e4eb..133b569c75159e 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -760,9 +760,7 @@ def icon(self) -> str | None: return self.entity_description.icon_fn(self.state) return self.entity_description.icon - @property # type: ignore[override] - # The device class might change at run time of the signal - # is not a number, so we override here. + @property def device_class(self) -> SensorDeviceClass | None: """Return device class for sensor.""" if self.entity_description.device_class_fn: diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index 947dcf2bacc097..a525c626f143a5 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -9,7 +9,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_MODE, @@ -159,7 +158,7 @@ def capability_attributes(self) -> dict[str, Any]: return data - @cached_property + @property def device_class(self) -> HumidifierDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index e43778a42c77c5..7640925451ac40 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,7 +10,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components.camera import Image from homeassistant.const import ( ATTR_ENTITY_ID, @@ -157,7 +156,7 @@ def confidence(self) -> float | None: return self.entity_description.confidence return None - @cached_property + @property def device_class(self) -> ImageProcessingDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index 9e7508c1bf1a6b..66a99b636816d8 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -242,14 +242,6 @@ def __init__( self._source_entity: str = source_entity self._last_valid_state: Decimal | None = None self._attr_device_info = device_info - self._device_class: SensorDeviceClass | None = None - - @property # type: ignore[override] - # The underlying source data may be unavailable at startup, so the device - # class may be set late so we need to override the property to disable the cache. - def device_class(self) -> SensorDeviceClass | None: - """Return the device class of the sensor.""" - return self._device_class def _unit(self, source_unit: str) -> str: """Derive unit from the source sensor, SI prefix and time unit.""" @@ -296,7 +288,7 @@ async def async_added_to_hass(self) -> None: err, ) - self._device_class = state.attributes.get(ATTR_DEVICE_CLASS) + self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS) self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) @callback @@ -327,7 +319,7 @@ def calc_integration(event: EventType[EventStateChangedData]) -> None: and new_state.attributes.get(ATTR_DEVICE_CLASS) == SensorDeviceClass.POWER ): - self._device_class = SensorDeviceClass.ENERGY + self._attr_device_class = SensorDeviceClass.ENERGY self._attr_icon = None self.async_write_ha_state() diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index fc908fe1098adc..2acb516fa95b97 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -22,7 +22,6 @@ import voluptuous as vol from yarl import URL -from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR @@ -496,7 +495,7 @@ class MediaPlayerEntity(Entity): _attr_volume_level: float | None = None # Implement these for your media player - @cached_property + @property def device_class(self) -> MediaPlayerDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py index 65155cbe77e1bd..69ecb913c98942 100644 --- a/homeassistant/components/mobile_app/binary_sensor.py +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -67,7 +67,7 @@ def handle_sensor_registration(data): ) -class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): # type: ignore[misc] +class MobileAppBinarySensor(MobileAppEntity, BinarySensorEntity): """Representation of an mobile app binary sensor.""" @property diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py index bee2ba9674506f..120014d1d52a37 100644 --- a/homeassistant/components/mobile_app/entity.py +++ b/homeassistant/components/mobile_app/entity.py @@ -69,9 +69,7 @@ def entity_registry_enabled_default(self) -> bool: """Return if entity should be enabled by default.""" return not self._config.get(ATTR_SENSOR_DISABLED) - @property # type: ignore[override,unused-ignore] - # Because the device class is received later from the mobile app - # we do not want to cache the property + @property def device_class(self): """Return the device class.""" return self._config.get(ATTR_SENSOR_DEVICE_CLASS) diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py index 9e00b45d1e3e6f..fc325b1b6e97a7 100644 --- a/homeassistant/components/mobile_app/sensor.py +++ b/homeassistant/components/mobile_app/sensor.py @@ -76,7 +76,7 @@ def handle_sensor_registration(data): ) -class MobileAppSensor(MobileAppEntity, RestoreSensor): # type: ignore[misc] +class MobileAppSensor(MobileAppEntity, RestoreSensor): """Representation of an mobile app sensor.""" async def async_restore_last_state(self, last_state): diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index ff6926261a6777..aa3566c5a95d89 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -12,7 +12,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature from homeassistant.core import HomeAssistant, ServiceCall, callback @@ -232,7 +231,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @cached_property + @property def device_class(self) -> NumberDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index b212e509a90e19..6b4e4a17fc2261 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -11,7 +11,6 @@ from math import ceil, floor, isfinite, log10 from typing import Any, Final, Self, cast, final -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry # pylint: disable-next=hass-deprecated-import @@ -260,7 +259,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class not in (None, SensorDeviceClass.ENUM) - @cached_property + @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/statistics/sensor.py b/homeassistant/components/statistics/sensor.py index 07bccd7522fd6a..e86a4741080aff 100644 --- a/homeassistant/components/statistics/sensor.py +++ b/homeassistant/components/statistics/sensor.py @@ -393,9 +393,7 @@ def _derive_unit_of_measurement(self, new_state: State) -> str | None: unit = base_unit + "/s" return unit - @property # type: ignore[override] - # Since the underlying data source may not be available at startup - # we disable the caching of device_class. + @property def device_class(self) -> SensorDeviceClass | None: """Return the class of this device.""" if self._state_characteristic in STATS_DATETIME: diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index a443fa783cfe6f..bf3c3424142e04 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( SERVICE_TOGGLE, @@ -103,7 +102,7 @@ class SwitchEntity(ToggleEntity): entity_description: SwitchEntityDescription _attr_device_class: SwitchDeviceClass | None - @cached_property + @property def device_class(self) -> SwitchDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/template/weather.py b/homeassistant/components/template/weather.py index 128b35dffb2277..a04fc7a641df3c 100644 --- a/homeassistant/components/template/weather.py +++ b/homeassistant/components/template/weather.py @@ -294,9 +294,7 @@ async def async_forecast_twice_daily(self) -> list[Forecast]: """Return the daily forecast in native units.""" return self._forecast_twice_daily - @property # type: ignore[override] - # Because attribution is a template, it can change at any time - # and we don't want to cache it. + @property def attribution(self) -> str | None: """Return the attribution.""" if self._attribution is None: diff --git a/homeassistant/components/time/__init__.py b/homeassistant/components/time/__init__.py index 6f83551488080b..26d40191fb959b 100644 --- a/homeassistant/components/time/__init__.py +++ b/homeassistant/components/time/__init__.py @@ -8,7 +8,6 @@ import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TIME from homeassistant.core import HomeAssistant, ServiceCall @@ -76,7 +75,7 @@ class TimeEntity(Entity): _attr_device_class: None = None _attr_state: None = None - @cached_property + @property @final def device_class(self) -> None: """Return the device class for the entity.""" diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 10aad4625ec709..668fe479e1f15d 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -552,7 +552,6 @@ class ProtectDeviceBinarySensor(ProtectDeviceEntity, BinarySensorEntity): device: Camera | Light | Sensor entity_description: ProtectBinaryEntityDescription - _device_class: BinarySensorDeviceClass | None @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -562,17 +561,9 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: self._attr_is_on = entity_description.get_ufp_value(updated_device) # UP Sense can be any of the 3 contact sensor device classes if entity_description.key == _KEY_DOOR and isinstance(updated_device, Sensor): - self._device_class = MOUNT_DEVICE_CLASS_MAP.get( - self.device.mount_type, BinarySensorDeviceClass.DOOR + entity_description.device_class = MOUNT_DEVICE_CLASS_MAP.get( + updated_device.mount_type, BinarySensorDeviceClass.DOOR ) - else: - self._device_class = self.entity_description.device_class - - @property # type: ignore[override] - # UFP smart sensors can change device class at runtime - def device_class(self) -> BinarySensorDeviceClass | None: - """Return the class of this sensor.""" - return self._device_class class ProtectDiskBinarySensor(ProtectNVREntity, BinarySensorEntity): diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e27a9b8e422ffc..e23032e24fe75d 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -11,7 +11,6 @@ from awesomeversion import AwesomeVersion, AwesomeVersionCompareException import voluptuous as vol -from homeassistant.backports.functools import cached_property from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory @@ -224,7 +223,7 @@ def _default_to_device_class_name(self) -> bool: """ return self.device_class is not None - @cached_property + @property def device_class(self) -> UpdateDeviceClass | None: """Return the class of this entity.""" if hasattr(self, "_attr_device_class"): diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 64d7c8ddb3d1c0..c32bd5eeb67a74 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -8,7 +8,6 @@ from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.security import IasZone -from homeassistant.backports.functools import cached_property from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, BinarySensorEntity, @@ -196,7 +195,7 @@ def name(self) -> str | None: zone_type = self._cluster_handler.cluster.get("zone_type") return IAS_ZONE_NAME_MAPPING.get(zone_type, "iaszone") - @cached_property + @property def device_class(self) -> BinarySensorDeviceClass | None: """Return device class from component DEVICE_CLASSES.""" zone_type = self._cluster_handler.cluster.get("zone_type") diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3ec91d6647b862..3c22288a1d6965 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -645,13 +645,6 @@ def native_unit_of_measurement(self) -> str | None: return None return str(self.info.primary_value.metadata.unit) - @property # type: ignore[override] - # fget is used in the child classes which is not compatible with cached_property - # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 - def device_class(self) -> SensorDeviceClass | None: - """Return device class of sensor.""" - return super().device_class - class ZWaveNumericSensor(ZwaveSensor): """Representation of a Z-Wave Numeric sensor.""" @@ -744,9 +737,7 @@ def options(self) -> list[str] | None: return list(self.info.primary_value.metadata.states.values()) return None - @property # type: ignore[override] - # fget is used which is not compatible with cached_property - # mypy also doesn't know about fget: https://github.com/python/mypy/issues/6185 + @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" if (device_class := super().device_class) is not None: @@ -790,7 +781,7 @@ def __init__( additional_info=[property_key_name] if property_key_name else None, ) - @property # type: ignore[override] + @property def device_class(self) -> SensorDeviceClass | None: """Return sensor device class.""" # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index ac43e2de956e46..5ed16408388545 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -550,7 +550,7 @@ def device_info(self) -> DeviceInfo | None: """ return self._attr_device_info - @cached_property + @property def device_class(self) -> str | None: """Return the class of this device, from component DEVICE_CLASSES.""" if hasattr(self, "_attr_device_class"): @@ -639,7 +639,7 @@ def entity_registry_visible_default(self) -> bool: return self.entity_description.entity_registry_visible_default return True - @cached_property + @property def attribution(self) -> str | None: """Return the attribution.""" return self._attr_attribution @@ -653,7 +653,7 @@ def entity_category(self) -> EntityCategory | None: return self.entity_description.entity_category return None - @cached_property + @property def translation_key(self) -> str | None: """Return the translation key to translate the entity's states.""" if hasattr(self, "_attr_translation_key"): diff --git a/tests/components/event/test_init.py b/tests/components/event/test_init.py index 7e00180f1fcfa0..66cda6a088a3bb 100644 --- a/tests/components/event/test_init.py +++ b/tests/components/event/test_init.py @@ -51,7 +51,6 @@ async def test_event() -> None: event.event_types # Test retrieving data from entity description - del event.device_class event.entity_description = EventEntityDescription( key="test_event", event_types=["short_press", "long_press"], @@ -64,7 +63,6 @@ async def test_event() -> None: event._attr_event_types = ["short_press", "long_press", "double_press"] assert event.event_types == ["short_press", "long_press", "double_press"] event._attr_device_class = EventDeviceClass.BUTTON - del event.device_class assert event.device_class == EventDeviceClass.BUTTON # Test triggering an event diff --git a/tests/components/update/test_init.py b/tests/components/update/test_init.py index 68bd62dabfef89..73f98c9e2db06c 100644 --- a/tests/components/update/test_init.py +++ b/tests/components/update/test_init.py @@ -59,13 +59,11 @@ class MockUpdateEntity(UpdateEntity): """Mock UpdateEntity to use in tests.""" -def _create_mock_update_entity( - hass: HomeAssistant, -) -> MockUpdateEntity: - mock_platform = MockEntityPlatform(hass) +async def test_update(hass: HomeAssistant) -> None: + """Test getting data from the mocked update entity.""" update = MockUpdateEntity() update.hass = hass - update.platform = mock_platform + update.platform = MockEntityPlatform(hass) update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.1" @@ -73,13 +71,6 @@ def _create_mock_update_entity( update._attr_release_url = "https://example.com" update._attr_title = "Title" - return update - - -async def test_update(hass: HomeAssistant) -> None: - """Test getting data from the mocked update entity.""" - update = _create_mock_update_entity(hass) - assert update.entity_category is EntityCategory.DIAGNOSTIC assert ( update.entity_picture @@ -102,6 +93,7 @@ async def test_update(hass: HomeAssistant) -> None: ATTR_SKIPPED_VERSION: None, ATTR_TITLE: "Title", } + # Test no update available update._attr_installed_version = "1.0.0" update._attr_latest_version = "1.0.0" @@ -128,19 +120,14 @@ async def test_update(hass: HomeAssistant) -> None: assert update.state is STATE_ON # Test entity category becomes config when its possible to install - update = _create_mock_update_entity(hass) update._attr_supported_features = UpdateEntityFeature.INSTALL assert update.entity_category is EntityCategory.CONFIG # UpdateEntityDescription was set - update = _create_mock_update_entity(hass) update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription(key="F5 - Its very refreshing") assert update.device_class is None assert update.entity_category is EntityCategory.CONFIG - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update.entity_description = UpdateEntityDescription( key="F5 - Its very refreshing", device_class=UpdateDeviceClass.FIRMWARE, @@ -150,24 +137,14 @@ async def test_update(hass: HomeAssistant) -> None: assert update.entity_category is None # Device class via attribute (override entity description) - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_device_class = None assert update.device_class is None - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_device_class = UpdateDeviceClass.FIRMWARE assert update.device_class is UpdateDeviceClass.FIRMWARE # Entity Attribute via attribute (override entity description) - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_entity_category = None assert update.entity_category is None - - update = _create_mock_update_entity(hass) - update._attr_supported_features = 0 update._attr_entity_category = EntityCategory.DIAGNOSTIC assert update.entity_category is EntityCategory.DIAGNOSTIC diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 2961210f5eca4c..61ee38a66a7a96 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -98,13 +98,9 @@ class TestHelpersEntity: def setup_method(self, method): """Set up things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self._create_entity() - - def _create_entity(self) -> None: self.entity = entity.Entity() self.entity.entity_id = "test.overwrite_hidden_true" - self.entity.hass = self.hass + self.hass = self.entity.hass = get_test_home_assistant() self.entity.schedule_update_ha_state() self.hass.block_till_done() @@ -127,7 +123,6 @@ def test_device_class(self): with patch( "homeassistant.helpers.entity.Entity.device_class", new="test_class" ): - self._create_entity() self.entity.schedule_update_ha_state() self.hass.block_till_done() state = self.hass.states.get(self.entity.entity_id) From 1737b27dd4eac3d829bb342cd072a034910e7a11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Sep 2023 12:58:56 +0200 Subject: [PATCH 538/640] Generate withings webhook ID in config flow (#100395) --- homeassistant/components/withings/config_flow.py | 5 +++-- tests/components/withings/test_config_flow.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/withings/config_flow.py b/homeassistant/components/withings/config_flow.py index cce1c5ee23ccd0..4dd123468a01f1 100644 --- a/homeassistant/components/withings/config_flow.py +++ b/homeassistant/components/withings/config_flow.py @@ -8,8 +8,9 @@ import voluptuous as vol from withings_api.common import AuthScope +from homeassistant.components.webhook import async_generate_id from homeassistant.config_entries import ConfigEntry, OptionsFlowWithConfigEntry -from homeassistant.const import CONF_TOKEN +from homeassistant.const import CONF_TOKEN, CONF_WEBHOOK_ID from homeassistant.core import callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers import config_entry_oauth2_flow @@ -77,7 +78,7 @@ async def async_oauth_create_entry(self, data: dict[str, Any]) -> FlowResult: return self.async_create_entry( title=DEFAULT_TITLE, - data=data, + data={**data, CONF_WEBHOOK_ID: async_generate_id()}, options={CONF_USE_WEBHOOK: False}, ) diff --git a/tests/components/withings/test_config_flow.py b/tests/components/withings/test_config_flow.py index 52a584e2513cf5..d5745ae9bedf07 100644 --- a/tests/components/withings/test_config_flow.py +++ b/tests/components/withings/test_config_flow.py @@ -73,6 +73,7 @@ async def test_full_flow( assert "result" in result assert result["result"].unique_id == "600" assert "token" in result["result"].data + assert "webhook_id" in result["result"].data assert result["result"].data["token"]["access_token"] == "mock-access-token" assert result["result"].data["token"]["refresh_token"] == "mock-refresh-token" From c173ebd11ab7a5f71bfd9578a1510029cfda06d9 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 13:49:33 +0200 Subject: [PATCH 539/640] Add device_address to modbus configuration (#100399) --- homeassistant/components/modbus/__init__.py | 4 +++- homeassistant/components/modbus/base_platform.py | 3 ++- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/validators.py | 4 +++- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index a3c8928caafb50..c228ba644598ab 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -62,6 +62,7 @@ CONF_CLIMATES, CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, @@ -138,7 +139,8 @@ { vol.Required(CONF_NAME): cv.string, vol.Required(CONF_ADDRESS): cv.positive_int, - vol.Optional(CONF_SLAVE, default=0): cv.positive_int, + vol.Exclusive(CONF_DEVICE_ADDRESS, "slave_addr"): cv.positive_int, + vol.Exclusive(CONF_SLAVE, "slave_addr"): cv.positive_int, vol.Optional( CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL ): cv.positive_int, diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index a3876bbe87c6be..739f234e8c439e 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -42,6 +42,7 @@ CALL_TYPE_X_COILS, CALL_TYPE_X_REGISTER_HOLDINGS, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -76,7 +77,7 @@ class BasePlatform(Entity): def __init__(self, hub: ModbusHub, entry: dict[str, Any]) -> None: """Initialize the Modbus binary sensor.""" self._hub = hub - self._slave = entry.get(CONF_SLAVE, 0) + self._slave = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) self._address = int(entry[CONF_ADDRESS]) self._input_type = entry[CONF_INPUT_TYPE] self._value: str | None = None diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index e509577267c3a4..7776cf96e70c74 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -17,6 +17,7 @@ CONF_CLIMATES = "climates" CONF_CLOSE_COMM_ON_ERROR = "close_comm_on_error" CONF_DATA_TYPE = "data_type" +CONF_DEVICE_ADDRESS = "device_address" CONF_FANS = "fans" CONF_INPUT_TYPE = "input_type" CONF_LAZY_ERROR = "lazy_error_count" diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index aec781b065ecc1..4297bf46cfe9e3 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -25,6 +25,7 @@ from .const import ( CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_SLAVE_COUNT, CONF_SWAP, @@ -241,7 +242,8 @@ def duplicate_entity_validator(config: dict) -> dict: addr += "_" + str(entry[CONF_COMMAND_ON]) if CONF_COMMAND_OFF in entry: addr += "_" + str(entry[CONF_COMMAND_OFF]) - addr += "_" + str(entry.get(CONF_SLAVE, 0)) + inx = entry.get(CONF_SLAVE, None) or entry.get(CONF_DEVICE_ADDRESS, 0) + addr += "_" + str(inx) if addr in addresses: err = ( f"Modbus {component}/{name} address {addr} is duplicate, second" From ec2364ef439a9382cdf3dfe277d806f7a35fa257 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 14:00:02 +0200 Subject: [PATCH 540/640] Add virtual_count == slave_count in modbus configuration (#100398) * Add virtual_count as config parameter. * Review (other PR) comments. * Review. * Review comment. --- homeassistant/components/modbus/__init__.py | 7 +++++-- homeassistant/components/modbus/base_platform.py | 5 ++++- homeassistant/components/modbus/binary_sensor.py | 11 +++++++++-- homeassistant/components/modbus/const.py | 1 + homeassistant/components/modbus/sensor.py | 6 ++++-- homeassistant/components/modbus/validators.py | 7 ++++++- 6 files changed, 29 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index c228ba644598ab..5f3ddd7a4b5d53 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -106,6 +106,7 @@ CONF_TARGET_TEMP, CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_REGISTERS, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, @@ -310,7 +311,8 @@ vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, + vol.Optional(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, vol.Optional(CONF_NAN_VALUE): nan_validator, @@ -330,7 +332,8 @@ CALL_TYPE_REGISTER_INPUT, ] ), - vol.Optional(CONF_SLAVE_COUNT, default=0): cv.positive_int, + vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_bin_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_bin_count"): cv.positive_int, } ) diff --git a/homeassistant/components/modbus/base_platform.py b/homeassistant/components/modbus/base_platform.py index 739f234e8c439e..ee98b51b72ad21 100644 --- a/homeassistant/components/modbus/base_platform.py +++ b/homeassistant/components/modbus/base_platform.py @@ -59,6 +59,7 @@ CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, CONF_VERIFY, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, CONF_ZERO_SUPPRESS, SIGNAL_START_ENTITY, @@ -166,7 +167,9 @@ def __init__(self, hub: ModbusHub, config: dict) -> None: if self._scale < 1 and not self._precision: self._precision = 2 self._offset = config[CONF_OFFSET] - self._slave_count = config.get(CONF_SLAVE_COUNT, 0) + self._slave_count = config.get(CONF_SLAVE_COUNT, None) or config.get( + CONF_VIRTUAL_COUNT, 0 + ) self._slave_size = self._count = config[CONF_COUNT] def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]: diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 05668bac0a9477..3dabeee081c3b7 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -24,7 +24,12 @@ from . import get_hub from .base_platform import BasePlatform -from .const import CALL_TYPE_COIL, CALL_TYPE_DISCRETE, CONF_SLAVE_COUNT +from .const import ( + CALL_TYPE_COIL, + CALL_TYPE_DISCRETE, + CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, +) from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -46,7 +51,9 @@ async def async_setup_platform( sensors: list[ModbusBinarySensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_BINARY_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusBinarySensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) diff --git a/homeassistant/components/modbus/const.py b/homeassistant/components/modbus/const.py index 7776cf96e70c74..92a38bb5e921c6 100644 --- a/homeassistant/components/modbus/const.py +++ b/homeassistant/components/modbus/const.py @@ -62,6 +62,7 @@ CONF_HVAC_MODE_FAN_ONLY = "state_fan_only" CONF_WRITE_REGISTERS = "write_registers" CONF_VERIFY = "verify" +CONF_VIRTUAL_COUNT = "virtual_count" CONF_WRITE_TYPE = "write_type" CONF_ZERO_SUPPRESS = "zero_suppress" diff --git a/homeassistant/components/modbus/sensor.py b/homeassistant/components/modbus/sensor.py index f2ed504b41b5e5..d7a6b4cca0f100 100644 --- a/homeassistant/components/modbus/sensor.py +++ b/homeassistant/components/modbus/sensor.py @@ -28,7 +28,7 @@ from . import get_hub from .base_platform import BaseStructPlatform -from .const import CONF_SLAVE_COUNT +from .const import CONF_SLAVE_COUNT, CONF_VIRTUAL_COUNT from .modbus import ModbusHub _LOGGER = logging.getLogger(__name__) @@ -50,7 +50,9 @@ async def async_setup_platform( sensors: list[ModbusRegisterSensor | SlaveSensor] = [] hub = get_hub(hass, discovery_info[CONF_NAME]) for entry in discovery_info[CONF_SENSORS]: - slave_count = entry.get(CONF_SLAVE_COUNT, 0) + slave_count = entry.get(CONF_SLAVE_COUNT, None) or entry.get( + CONF_VIRTUAL_COUNT, 0 + ) sensor = ModbusRegisterSensor(hub, entry, slave_count) if slave_count > 0: sensors.extend(await sensor.async_setup_slaves(hass, slave_count, entry)) diff --git a/homeassistant/components/modbus/validators.py b/homeassistant/components/modbus/validators.py index 4297bf46cfe9e3..ca08ace853ae48 100644 --- a/homeassistant/components/modbus/validators.py +++ b/homeassistant/components/modbus/validators.py @@ -33,6 +33,7 @@ CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_WRITE_TYPE, DEFAULT_HUB, DEFAULT_SCAN_INTERVAL, @@ -98,6 +99,10 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: count = config.get(CONF_COUNT, None) structure = config.get(CONF_STRUCTURE, None) slave_count = config.get(CONF_SLAVE_COUNT, None) + slave_name = CONF_SLAVE_COUNT + if not slave_count: + slave_count = config.get(CONF_VIRTUAL_COUNT, 0) + slave_name = CONF_VIRTUAL_COUNT swap_type = config.get(CONF_SWAP, CONF_SWAP_NONE) validator = DEFAULT_STRUCT_FORMAT[data_type].validate_parm if count and not validator.count: @@ -113,7 +118,7 @@ def struct_validator(config: dict[str, Any]) -> dict[str, Any]: error = f"{name}: `{CONF_STRUCTURE}` missing or empty, demanded with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if slave_count and not validator.slave_count: - error = f"{name}: `{CONF_SLAVE_COUNT}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" + error = f"{name}: `{slave_name}: {slave_count}` cannot be combined with `{CONF_DATA_TYPE}: {data_type}`" raise vol.Invalid(error) if swap_type != CONF_SWAP_NONE: swap_type_validator = { From 5ac149a7603a9c81fd9950b55b2ced0748fdd038 Mon Sep 17 00:00:00 2001 From: Seth <48533968+WillCodeForCats@users.noreply.github.com> Date: Fri, 15 Sep 2023 05:33:17 -0700 Subject: [PATCH 541/640] Remove state class from RainMachine TIMESTAMP sensors (#100400) --- homeassistant/components/rainmachine/sensor.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/homeassistant/components/rainmachine/sensor.py b/homeassistant/components/rainmachine/sensor.py index 6333dcc82f4699..bdae62c1bd8d30 100644 --- a/homeassistant/components/rainmachine/sensor.py +++ b/homeassistant/components/rainmachine/sensor.py @@ -141,7 +141,6 @@ class RainMachineSensorCompletionTimerDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="lastLeakDetected", ), @@ -152,7 +151,6 @@ class RainMachineSensorCompletionTimerDescription( entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, device_class=SensorDeviceClass.TIMESTAMP, - state_class=SensorStateClass.MEASUREMENT, api_category=DATA_PROVISION_SETTINGS, data_key="rainSensorRainStart", ), From b4c095e944df7160867b0c91014407cd1f538746 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Fri, 15 Sep 2023 14:42:27 +0200 Subject: [PATCH 542/640] Add missing timer service translation (#100388) --- homeassistant/components/timer/services.yaml | 2 ++ homeassistant/components/timer/strings.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/homeassistant/components/timer/services.yaml b/homeassistant/components/timer/services.yaml index 74eeae22b23051..ac5453d38c913d 100644 --- a/homeassistant/components/timer/services.yaml +++ b/homeassistant/components/timer/services.yaml @@ -36,3 +36,5 @@ change: example: "00:01:00, 60 or -60" selector: text: + +reload: diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 56cb46d26b4583..719cafe676a4a7 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -62,6 +62,10 @@ "description": "Duration to add or subtract to the running timer." } } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." } } } From 9eb0b844bc67a4abd3593e0e70c6558c0390bf15 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 15:02:24 +0200 Subject: [PATCH 543/640] Test VIRTUAL_COUNT parameter (#100434) --- tests/components/modbus/test_binary_sensor.py | 46 +++++++- tests/components/modbus/test_init.py | 19 +++ tests/components/modbus/test_sensor.py | 111 ++++++++++++++++-- 3 files changed, 161 insertions(+), 15 deletions(-) diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 1e413fcc7640fe..7f668b26e045be 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -11,6 +11,7 @@ CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, + CONF_VIRTUAL_COUNT, MODBUS_DOMAIN, ) from homeassistant.const import ( @@ -265,7 +266,7 @@ async def test_service_binary_sensor_update( CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, @@ -294,9 +295,18 @@ async def test_restore_state_binary_sensor( } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_VIRTUAL_COUNT: 3, + } + ] + }, ], ) -async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: +async def test_config_virtual_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: """Run config test for binary sensor.""" assert SENSOR_DOMAIN in hass.config.components @@ -355,33 +365,63 @@ async def test_config_slave_binary_sensor(hass: HomeAssistant, mock_modbus) -> N STATE_OFF, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False] * 8, + STATE_OFF, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True] + [False] * 7, STATE_ON, [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True] + [False] * 7, + STATE_ON, + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [False, True] + [False] * 6, STATE_OFF, [STATE_ON], ), + ( + {CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [False, True] + [False] * 6, + STATE_OFF, + [STATE_ON], + ), ( {CONF_SLAVE_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 4, STATE_ON, [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 7, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 4, + STATE_ON, + [STATE_OFF, STATE_ON] * 3 + [STATE_OFF], + ), ( {CONF_SLAVE_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, [True, False] * 16, STATE_ON, [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], ), + ( + {CONF_VIRTUAL_COUNT: 31, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID}, + [True, False] * 16, + STATE_ON, + [STATE_OFF, STATE_ON] * 15 + [STATE_OFF], + ), ], ) -async def test_slave_binary_sensor( +async def test_virtual_binary_sensor( hass: HomeAssistant, expected, slaves, mock_do_cycle ) -> None: """Run test for given config.""" diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index c2f3e6395805c8..a68ac2d3738377 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -50,6 +50,7 @@ CONF_SWAP_BYTE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, DEFAULT_SCAN_INTERVAL, MODBUS_DOMAIN as DOMAIN, RTUOVERTCP, @@ -263,11 +264,23 @@ async def test_ok_struct_validator(do_config) -> None: CONF_STRUCTURE: ">f", CONF_SLAVE_COUNT: 5, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_DATA_TYPE: DataType.CUSTOM, + CONF_STRUCTURE: ">f", + CONF_VIRTUAL_COUNT: 5, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.STRING, CONF_SLAVE_COUNT: 2, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_DATA_TYPE: DataType.STRING, + CONF_VIRTUAL_COUNT: 2, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, @@ -279,6 +292,12 @@ async def test_ok_struct_validator(do_config) -> None: CONF_SLAVE_COUNT: 2, CONF_DATA_TYPE: DataType.INT32, }, + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_COUNT: 2, + CONF_VIRTUAL_COUNT: 2, + CONF_DATA_TYPE: DataType.INT32, + }, { CONF_NAME: TEST_ENTITY_NAME, CONF_DATA_TYPE: DataType.INT16, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 14bccbafac4650..0833c0e2f7fc47 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -21,6 +21,7 @@ CONF_SWAP_NONE, CONF_SWAP_WORD, CONF_SWAP_WORD_BYTE, + CONF_VIRTUAL_COUNT, CONF_ZERO_SUPPRESS, MODBUS_DOMAIN, DataType, @@ -150,6 +151,16 @@ } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DATA_TYPE: DataType.INT32, + CONF_VIRTUAL_COUNT: 5, + } + ] + }, ], ) async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -671,6 +682,21 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["34899771392", "0"], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + CONF_DATA_TYPE: DataType.FLOAT32, + }, + [ + 0x5102, + 0x0304, + int.from_bytes(struct.pack(">f", float("nan"))[0:2]), + int.from_bytes(struct.pack(">f", float("nan"))[2:4]), + ], + False, + ["34899771392", "0"], + ), ( { CONF_SLAVE_COUNT: 0, @@ -680,6 +706,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060"], ), + ( + { + CONF_VIRTUAL_COUNT: 0, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304], + False, + ["16909060"], + ), ( { CONF_SLAVE_COUNT: 1, @@ -689,6 +724,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060", "67305985"], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + False, + ["16909060", "67305985"], + ), ( { CONF_SLAVE_COUNT: 3, @@ -712,6 +756,29 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: "219025152", ], ), + ( + { + CONF_VIRTUAL_COUNT: 3, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [ + 0x0102, + 0x0304, + 0x0506, + 0x0708, + 0x090A, + 0x0B0C, + 0x0D0E, + 0x0F00, + ], + False, + [ + "16909060", + "84281096", + "151653132", + "219025152", + ], + ), ( { CONF_SLAVE_COUNT: 1, @@ -721,6 +788,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: True, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201], + True, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 1, @@ -730,9 +806,18 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, [STATE_UNAVAILABLE, STATE_UNKNOWN], ), + ( + { + CONF_VIRTUAL_COUNT: 1, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN], + ), ], ) -async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: """Run test for sensor.""" entity_registry = er.async_get(hass) for i in range(0, len(expected)): @@ -766,7 +851,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non [ ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_BYTE, CONF_DATA_TYPE: DataType.UINT16, @@ -777,7 +862,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT32, @@ -788,7 +873,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 0, + CONF_VIRTUAL_COUNT: 0, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_SWAP: CONF_SWAP_WORD, CONF_DATA_TYPE: DataType.UINT64, @@ -799,7 +884,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -810,7 +895,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -821,7 +906,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -832,7 +917,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT16, CONF_SWAP: CONF_SWAP_BYTE, @@ -843,7 +928,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT32, CONF_SWAP: CONF_SWAP_WORD, @@ -868,7 +953,7 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ( { - CONF_SLAVE_COUNT: 3, + CONF_VIRTUAL_COUNT: 3, CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, CONF_DATA_TYPE: DataType.UINT64, CONF_SWAP: CONF_SWAP_WORD, @@ -901,7 +986,9 @@ async def test_slave_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> Non ), ], ) -async def test_slave_swap_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: +async def test_virtual_swap_sensor( + hass: HomeAssistant, mock_do_cycle, expected +) -> None: """Run test for sensor.""" for i in range(0, len(expected)): entity_id = f"{SENSOR_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_") @@ -1230,7 +1317,7 @@ async def mock_restore(hass): CONF_NAME: TEST_ENTITY_NAME, CONF_ADDRESS: 51, CONF_SCAN_INTERVAL: 0, - CONF_SLAVE_COUNT: 1, + CONF_VIRTUAL_COUNT: 1, } ] }, From b329439fff23814d53c4966e535b1b6343bb4d53 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Fri, 15 Sep 2023 16:05:56 +0200 Subject: [PATCH 544/640] Fix timer reload description (#100433) Fix copy/paste error of #100388 --- homeassistant/components/timer/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/timer/strings.json b/homeassistant/components/timer/strings.json index 719cafe676a4a7..1ebf0c6f50a435 100644 --- a/homeassistant/components/timer/strings.json +++ b/homeassistant/components/timer/strings.json @@ -65,7 +65,7 @@ }, "reload": { "name": "[%key:common::action::reload%]", - "description": "Reloads helpers from the YAML-configuration." + "description": "Reloads timers from the YAML-configuration." } } } From fd83f7d87f991185ffa8349976f2df5752f1afbd Mon Sep 17 00:00:00 2001 From: jan iversen Date: Fri, 15 Sep 2023 16:12:44 +0200 Subject: [PATCH 545/640] Add test for modbus CONF_DEVICE_ADDR (#100435) --- homeassistant/components/modbus/__init__.py | 2 +- tests/components/modbus/conftest.py | 5 +--- tests/components/modbus/test_binary_sensor.py | 25 ++++++++++++++++++- tests/components/modbus/test_climate.py | 11 ++++++++ tests/components/modbus/test_cover.py | 13 ++++++++++ tests/components/modbus/test_fan.py | 19 ++++++++++++++ tests/components/modbus/test_init.py | 15 +++++++++++ tests/components/modbus/test_light.py | 18 +++++++++++++ tests/components/modbus/test_sensor.py | 18 +++++++++++++ tests/components/modbus/test_switch.py | 19 ++++++++++++++ 10 files changed, 139 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 5f3ddd7a4b5d53..875669e6dd7fce 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -312,7 +312,7 @@ vol.Optional(CONF_STATE_CLASS): SENSOR_STATE_CLASSES_SCHEMA, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, - vol.Optional(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, + vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Optional(CONF_MIN_VALUE): number_validator, vol.Optional(CONF_MAX_VALUE): number_validator, vol.Optional(CONF_NAN_VALUE): nan_validator, diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index d4c7dfa5e10e77..a08743b7e6c02d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -10,7 +10,7 @@ import pytest from homeassistant.components.modbus.const import MODBUS_DOMAIN as DOMAIN, TCP -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_SLAVE, CONF_TYPE +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, CONF_TYPE from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -87,9 +87,6 @@ async def mock_modbus_fixture( for key in conf: if config_addon: conf[key][0].update(config_addon) - for entity in conf[key]: - if CONF_SLAVE not in entity: - entity[CONF_SLAVE] = 0 caplog.set_level(logging.WARNING) config = { DOMAIN: [ diff --git a/tests/components/modbus/test_binary_sensor.py b/tests/components/modbus/test_binary_sensor.py index 7f668b26e045be..2069aa23b8fc0f 100644 --- a/tests/components/modbus/test_binary_sensor.py +++ b/tests/components/modbus/test_binary_sensor.py @@ -8,6 +8,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_SLAVE_COUNT, @@ -60,6 +61,18 @@ } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_DISCRETE, + CONF_DEVICE_CLASS: "door", + CONF_LAZY_ERROR: 10, + } + ] + }, { CONF_BINARY_SENSORS: [ { @@ -70,6 +83,16 @@ } ] }, + { + CONF_BINARY_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_INPUT, + } + ] + }, ], ) async def test_config_binary_sensor(hass: HomeAssistant, mock_modbus) -> None: @@ -299,7 +322,7 @@ async def test_restore_state_binary_sensor( CONF_BINARY_SENSORS: [ { CONF_NAME: TEST_ENTITY_NAME, - CONF_ADDRESS: 51, + CONF_ADDRESS: 52, CONF_VIRTUAL_COUNT: 3, } ] diff --git a/tests/components/modbus/test_climate.py b/tests/components/modbus/test_climate.py index 4ab78df0c81987..f2de0177c74f03 100644 --- a/tests/components/modbus/test_climate.py +++ b/tests/components/modbus/test_climate.py @@ -11,6 +11,7 @@ from homeassistant.components.modbus.const import ( CONF_CLIMATES, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_HVAC_MODE_AUTO, CONF_HVAC_MODE_COOL, CONF_HVAC_MODE_DRY, @@ -57,6 +58,16 @@ } ], }, + { + CONF_CLIMATES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_TARGET_TEMP: 117, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 10, + } + ], + }, { CONF_CLIMATES: [ { diff --git a/tests/components/modbus/test_cover.py b/tests/components/modbus/test_cover.py index 66e4537d67eca3..b91b38b1f701e4 100644 --- a/tests/components/modbus/test_cover.py +++ b/tests/components/modbus/test_cover.py @@ -7,6 +7,7 @@ from homeassistant.components.modbus.const import ( CALL_TYPE_COIL, CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_CLOSED, @@ -62,6 +63,18 @@ } ] }, + { + CONF_COVERS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_ADDRESS: 10, + CONF_SCAN_INTERVAL: 20, + CONF_LAZY_ERROR: 10, + } + ] + }, ], ) async def test_config_cover(hass: HomeAssistant, mock_modbus) -> None: diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index 2d2cc83162d09e..e47ed5c2371c6f 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -8,6 +8,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_FANS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, @@ -75,6 +76,24 @@ } ] }, + { + CONF_FANS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_LAZY_ERROR: 10, + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_FANS: [ { diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index a68ac2d3738377..f9c7fb42b2d8e7 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -41,6 +41,7 @@ CONF_BAUDRATE, CONF_BYTESIZE, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, @@ -517,6 +518,20 @@ async def test_duplicate_entity_validator(do_config) -> None: } ], }, + { + # Special test for scan_interval validator with scan_interval: 0 + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 117, + CONF_DEVICE_ADDRESS: 0, + CONF_SCAN_INTERVAL: 0, + } + ], + }, ], ) async def test_config_modbus( diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index 46763b3b3a2306..a5871bdbd670db 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -8,6 +8,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -75,6 +76,23 @@ } ] }, + { + CONF_LIGHTS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_LIGHTS: [ { diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 0833c0e2f7fc47..46c38873a93d03 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -8,6 +8,7 @@ CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, CONF_DATA_TYPE, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_MAX_VALUE, @@ -86,6 +87,23 @@ } ] }, + { + CONF_SENSORS: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 51, + CONF_DEVICE_ADDRESS: 10, + CONF_DATA_TYPE: DataType.INT16, + CONF_PRECISION: 0, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_STATE_CLASS: SensorStateClass.MEASUREMENT, + CONF_LAZY_ERROR: 10, + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_DEVICE_CLASS: "battery", + } + ] + }, { CONF_SENSORS: [ { diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index 7a79e19869aa5b..ff7d6860f3b582 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -11,6 +11,7 @@ CALL_TYPE_DISCRETE, CALL_TYPE_REGISTER_HOLDING, CALL_TYPE_REGISTER_INPUT, + CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, CONF_LAZY_ERROR, CONF_STATE_OFF, @@ -85,6 +86,24 @@ } ] }, + { + CONF_SWITCHES: [ + { + CONF_NAME: TEST_ENTITY_NAME, + CONF_ADDRESS: 1234, + CONF_DEVICE_ADDRESS: 1, + CONF_COMMAND_OFF: 0x00, + CONF_COMMAND_ON: 0x01, + CONF_DEVICE_CLASS: "switch", + CONF_VERIFY: { + CONF_INPUT_TYPE: CALL_TYPE_REGISTER_HOLDING, + CONF_ADDRESS: 1235, + CONF_STATE_OFF: 0, + CONF_STATE_ON: 1, + }, + } + ] + }, { CONF_SWITCHES: [ { From 06949b181f9b487f206e2a32a69438a35898ea40 Mon Sep 17 00:00:00 2001 From: Matrix Date: Fri, 15 Sep 2023 23:20:30 +0800 Subject: [PATCH 546/640] Bump yolink-api to 0.3.1 (#100426) --- homeassistant/components/yolink/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/yolink/manifest.json b/homeassistant/components/yolink/manifest.json index ced0d527c7dbde..7322c58ae04fbe 100644 --- a/homeassistant/components/yolink/manifest.json +++ b/homeassistant/components/yolink/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["auth", "application_credentials"], "documentation": "https://www.home-assistant.io/integrations/yolink", "iot_class": "cloud_push", - "requirements": ["yolink-api==0.3.0"] + "requirements": ["yolink-api==0.3.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 0304faa1f08b4a..b0bd80c177fab3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2751,7 +2751,7 @@ yeelight==0.7.13 yeelightsunflower==0.0.10 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6063842f5fc8fb..f085364261f900 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2036,7 +2036,7 @@ yalexs==1.9.0 yeelight==0.7.13 # homeassistant.components.yolink -yolink-api==0.3.0 +yolink-api==0.3.1 # homeassistant.components.youless youless-api==1.0.1 From c9975852bb3b44d7a52a1a56130ee724b2bf2716 Mon Sep 17 00:00:00 2001 From: Kevin Stillhammer Date: Fri, 15 Sep 2023 19:03:04 +0200 Subject: [PATCH 547/640] bump pywaze to 0.5.0 (#100456) --- 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 c72d9b1dbad63f..1a4be79836747d 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.4.0"] + "requirements": ["pywaze==0.5.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index b0bd80c177fab3..1eaae711a290db 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2235,7 +2235,7 @@ pyvlx==0.2.20 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.4.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f085364261f900..232840ae917a2e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1649,7 +1649,7 @@ pyvizio==0.1.61 pyvolumio==0.1.5 # homeassistant.components.waze_travel_time -pywaze==0.4.0 +pywaze==0.5.0 # homeassistant.components.html5 pywebpush==1.9.2 From a4e0444b95231e312bc22263df72ba1c8026910d Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Fri, 15 Sep 2023 13:38:14 -0500 Subject: [PATCH 548/640] Bump sense-energy to 0.12.2 (#100459) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 6 ++---- requirements_test_all.txt | 6 ++---- 4 files changed, 6 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index d39d530ecccb73..843aeddde7bcde 100644 --- a/homeassistant/components/emulated_kasa/manifest.json +++ b/homeassistant/components/emulated_kasa/manifest.json @@ -6,5 +6,5 @@ "iot_class": "local_push", "loggers": ["sense_energy"], "quality_scale": "internal", - "requirements": ["sense_energy==0.12.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 8a89d6d8531dc5..7ef1caefe48907 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/sense", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.12.1"] + "requirements": ["sense-energy==0.12.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index 1eaae711a290db..a87177e296df1c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2372,11 +2372,9 @@ securetar==2023.3.0 # homeassistant.components.sendgrid sendgrid==6.8.2 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 232840ae917a2e..c2d44de0327568 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1741,11 +1741,9 @@ screenlogicpy==0.9.0 # homeassistant.components.backup securetar==2023.3.0 -# homeassistant.components.sense -sense-energy==0.12.1 - # homeassistant.components.emulated_kasa -sense_energy==0.12.1 +# homeassistant.components.sense +sense-energy==0.12.2 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From a111988232330a30e890429f208196bf52f7c3d5 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Fri, 15 Sep 2023 20:39:14 +0200 Subject: [PATCH 549/640] Make codespell ignore snapshots (#100463) --- .pre-commit-config.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b5fafdd6dab43e..b0c981433004cf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: - --skip="./.*,*.csv,*.json,*.ambr" - --quiet-level=2 exclude_types: [csv, json] - exclude: ^tests/fixtures/|homeassistant/generated/ + exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/ - repo: https://github.com/pre-commit/pre-commit-hooks rev: v4.4.0 hooks: From d25f45a957327a94489f697c96b2425b2d5def55 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 09:57:55 +0200 Subject: [PATCH 550/640] Harden modbus against lib errors (#100469) --- homeassistant/components/modbus/modbus.py | 6 ++++++ tests/components/modbus/conftest.py | 9 +++++++++ tests/components/modbus/test_fan.py | 5 ++++- tests/components/modbus/test_light.py | 5 ++++- tests/components/modbus/test_switch.py | 9 +++++++-- 5 files changed, 30 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index a503b71593c5aa..31179a2358301c 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -419,9 +419,15 @@ def pb_call( except ModbusException as exception_error: self._log_error(str(exception_error)) return None + if not result: + self._log_error("Error: pymodbus returned None") + return None if not hasattr(result, entry.attr): self._log_error(str(result)) return None + if result.isError(): # type: ignore[no-untyped-call] + self._log_error("Error: pymodbus returned isError True") + return None self._in_error = False return result diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index a08743b7e6c02d..460b1eb5dd315d 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -32,6 +32,11 @@ def __init__(self, register_words): """Init.""" self.registers = register_words self.bits = register_words + self.value = register_words + + def isError(self): + """Set error state.""" + return False @pytest.fixture(name="mock_pymodbus") @@ -136,6 +141,10 @@ async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result mock_modbus.read_holding_registers.return_value = read_result + mock_modbus.write_register.return_value = read_result + mock_modbus.write_registers.return_value = read_result + mock_modbus.write_coil.return_value = read_result + mock_modbus.write_coils.return_value = read_result @pytest.fixture(name="mock_do_cycle") diff --git a/tests/components/modbus/test_fan.py b/tests/components/modbus/test_fan.py index e47ed5c2371c6f..932e07b2d1a0ed 100644 --- a/tests/components/modbus/test_fan.py +++ b/tests/components/modbus/test_fan.py @@ -261,7 +261,10 @@ async def test_restore_state_fan( ], ) async def test_fan_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_light.py b/tests/components/modbus/test_light.py index a5871bdbd670db..1d6963aaa12eaf 100644 --- a/tests/components/modbus/test_light.py +++ b/tests/components/modbus/test_light.py @@ -260,7 +260,10 @@ async def test_restore_state_light( ], ) async def test_light_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" diff --git a/tests/components/modbus/test_switch.py b/tests/components/modbus/test_switch.py index ff7d6860f3b582..0eb40d2c08299e 100644 --- a/tests/components/modbus/test_switch.py +++ b/tests/components/modbus/test_switch.py @@ -316,7 +316,10 @@ async def test_restore_state_switch( ], ) async def test_switch_service_turn( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_modbus + hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, + mock_modbus, + mock_pymodbus_return, ) -> None: """Run test for service turn_on/turn_off.""" assert MODBUS_DOMAIN in hass.config.components @@ -407,7 +410,9 @@ async def test_service_switch_update(hass: HomeAssistant, mock_modbus, mock_ha) }, ], ) -async def test_delay_switch(hass: HomeAssistant, mock_modbus) -> None: +async def test_delay_switch( + hass: HomeAssistant, mock_modbus, mock_pymodbus_return +) -> None: """Run test for switch verify delay.""" mock_modbus.read_holding_registers.return_value = ReadResult([0x01]) now = dt_util.utcnow() From 9747e0091f99a92e8a9c6c9d79b1318ab8ddd7d6 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Sat, 16 Sep 2023 10:13:27 +0200 Subject: [PATCH 551/640] Use shorthand attrs for device_class zwave_js sensor (#100414) * Use shorthand attrs zwave_js sensor * Simplify --- homeassistant/components/zwave_js/sensor.py | 32 ++------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/homeassistant/components/zwave_js/sensor.py b/homeassistant/components/zwave_js/sensor.py index 3c22288a1d6965..8d42bcfb36698f 100644 --- a/homeassistant/components/zwave_js/sensor.py +++ b/homeassistant/components/zwave_js/sensor.py @@ -17,7 +17,7 @@ from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.node.statistics import NodeStatisticsDataType -from zwave_js_server.model.value import ConfigurationValue, ConfigurationValueType +from zwave_js_server.model.value import ConfigurationValue from zwave_js_server.util.command_class.meter import get_meter_type from homeassistant.components.sensor import ( @@ -729,22 +729,9 @@ def __init__( alternate_value_name=self.info.primary_value.property_name, additional_info=[self.info.primary_value.property_key_name], ) - - @property - def options(self) -> list[str] | None: - """Return options for enum sensor.""" - if self.device_class == SensorDeviceClass.ENUM: - return list(self.info.primary_value.metadata.states.values()) - return None - - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - if (device_class := super().device_class) is not None: - return device_class if self.info.primary_value.metadata.states: - return SensorDeviceClass.ENUM - return None + self._attr_device_class = SensorDeviceClass.ENUM + self._attr_options = list(info.primary_value.metadata.states.values()) @property def extra_state_attributes(self) -> dict[str, str] | None: @@ -781,19 +768,6 @@ def __init__( additional_info=[property_key_name] if property_key_name else None, ) - @property - def device_class(self) -> SensorDeviceClass | None: - """Return sensor device class.""" - # mypy doesn't know about fget: https://github.com/python/mypy/issues/6185 - if (device_class := ZwaveSensor.device_class.fget(self)) is not None: # type: ignore[attr-defined] - return device_class # type: ignore[no-any-return] - if ( - self._primary_value.configuration_value_type - == ConfigurationValueType.ENUMERATED - ): - return SensorDeviceClass.ENUM - return None - @property def extra_state_attributes(self) -> dict[str, str] | None: """Return the device specific state attributes.""" From c504ca906dd5f0cc35a532b5679b9bb5c5304a18 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:18:19 +0200 Subject: [PATCH 552/640] Move co2signal exceptions to their own file (#100473) * Move co2signal exceptions to their own file * Add myself as codeowner --- CODEOWNERS | 2 ++ .../components/co2signal/__init__.py | 19 ++----------------- .../components/co2signal/config_flow.py | 3 ++- .../components/co2signal/exceptions.py | 18 ++++++++++++++++++ .../components/co2signal/manifest.json | 2 +- 5 files changed, 25 insertions(+), 19 deletions(-) create mode 100644 homeassistant/components/co2signal/exceptions.py diff --git a/CODEOWNERS b/CODEOWNERS index 7463731e57a554..7c96042caa392e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -205,6 +205,8 @@ build.json @home-assistant/supervisor /tests/components/cloud/ @home-assistant/cloud /homeassistant/components/cloudflare/ @ludeeus @ctalkington /tests/components/cloudflare/ @ludeeus @ctalkington +/homeassistant/components/co2signal/ @jpbede +/tests/components/co2signal/ @jpbede /homeassistant/components/coinbase/ @tombrien /tests/components/coinbase/ @tombrien /homeassistant/components/color_extractor/ @GenericStudent diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 721a26e147f9ca..943fa13e240077 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -11,10 +11,11 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) @@ -86,22 +87,6 @@ async def _async_update_data(self) -> CO2SignalResponse: return data -class CO2Error(HomeAssistantError): - """Base error.""" - - -class InvalidAuth(CO2Error): - """Raised when invalid authentication credentials are provided.""" - - -class APIRatelimitExceeded(CO2Error): - """Raised when the API rate limit is exceeded.""" - - -class UnknownError(CO2Error): - """Raised when an unknown error occurs.""" - - def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: """Get data from the API.""" if CONF_COUNTRY_CODE in config: diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 036282cb3e8fc3..2ac3ebc398fae9 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,9 @@ from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import APIRatelimitExceeded, InvalidAuth, get_data +from . import get_data from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name TYPE_USE_HOME = "Use home location" diff --git a/homeassistant/components/co2signal/exceptions.py b/homeassistant/components/co2signal/exceptions.py new file mode 100644 index 00000000000000..cc8ee709bde3d4 --- /dev/null +++ b/homeassistant/components/co2signal/exceptions.py @@ -0,0 +1,18 @@ +"""Exceptions to the co2signal integration.""" +from homeassistant.exceptions import HomeAssistantError + + +class CO2Error(HomeAssistantError): + """Base error.""" + + +class InvalidAuth(CO2Error): + """Raised when invalid authentication credentials are provided.""" + + +class APIRatelimitExceeded(CO2Error): + """Raised when the API rate limit is exceeded.""" + + +class UnknownError(CO2Error): + """Raised when an unknown error occurs.""" diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index a0a3ee71a9cfd6..4ab4607cccce93 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -1,7 +1,7 @@ { "domain": "co2signal", "name": "Electricity Maps", - "codeowners": [], + "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", "iot_class": "cloud_polling", From 024db6dadfc0a0036a6de38d1ae8be78bd4603eb Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:19:05 +0200 Subject: [PATCH 553/640] Move cert_expiry coordinator to its own file (#100472) * Move cert_expiry coordinator to its own file * Add missing patched config flow test --- .../components/cert_expiry/__init__.py | 47 +---------------- .../components/cert_expiry/coordinator.py | 51 +++++++++++++++++++ .../cert_expiry/test_config_flow.py | 8 +-- tests/components/cert_expiry/test_init.py | 8 +-- tests/components/cert_expiry/test_sensors.py | 10 ++-- 5 files changed, 66 insertions(+), 58 deletions(-) create mode 100644 homeassistant/components/cert_expiry/coordinator.py diff --git a/homeassistant/components/cert_expiry/__init__.py b/homeassistant/components/cert_expiry/__init__.py index 5d1e68a951fdc7..391bb3ef8f32c7 100644 --- a/homeassistant/components/cert_expiry/__init__.py +++ b/homeassistant/components/cert_expiry/__init__.py @@ -1,22 +1,13 @@ """The cert_expiry component.""" from __future__ import annotations -from datetime import datetime, timedelta -import logging - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.start import async_at_started -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed - -from .const import DEFAULT_PORT, DOMAIN -from .errors import TemporaryFailure, ValidationFailure -from .helper import get_cert_expiry_timestamp - -_LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(hours=12) +from .const import DOMAIN +from .coordinator import CertExpiryDataUpdateCoordinator PLATFORMS = [Platform.SENSOR] @@ -45,37 +36,3 @@ async def _async_finish_startup(_): async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): - """Class to manage fetching Cert Expiry data from single endpoint.""" - - def __init__(self, hass, host, port): - """Initialize global Cert Expiry data updater.""" - self.host = host - self.port = port - self.cert_error = None - self.is_cert_valid = False - - display_port = f":{port}" if port != DEFAULT_PORT else "" - name = f"{self.host}{display_port}" - - super().__init__( - hass, _LOGGER, name=name, update_interval=SCAN_INTERVAL, always_update=False - ) - - async def _async_update_data(self) -> datetime | None: - """Fetch certificate.""" - try: - timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) - except TemporaryFailure as err: - raise UpdateFailed(err.args[0]) from err - except ValidationFailure as err: - self.cert_error = err - self.is_cert_valid = False - _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) - return None - - self.cert_error = None - self.is_cert_valid = True - return timestamp diff --git a/homeassistant/components/cert_expiry/coordinator.py b/homeassistant/components/cert_expiry/coordinator.py new file mode 100644 index 00000000000000..6a125758f7013d --- /dev/null +++ b/homeassistant/components/cert_expiry/coordinator.py @@ -0,0 +1,51 @@ +"""DataUpdateCoordinator for cert_expiry coordinator.""" +from __future__ import annotations + +from datetime import datetime, timedelta +import logging + +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_PORT +from .errors import TemporaryFailure, ValidationFailure +from .helper import get_cert_expiry_timestamp + +_LOGGER = logging.getLogger(__name__) + + +class CertExpiryDataUpdateCoordinator(DataUpdateCoordinator[datetime | None]): + """Class to manage fetching Cert Expiry data from single endpoint.""" + + def __init__(self, hass, host, port): + """Initialize global Cert Expiry data updater.""" + self.host = host + self.port = port + self.cert_error = None + self.is_cert_valid = False + + display_port = f":{port}" if port != DEFAULT_PORT else "" + name = f"{self.host}{display_port}" + + super().__init__( + hass, + _LOGGER, + name=name, + update_interval=timedelta(hours=12), + always_update=False, + ) + + async def _async_update_data(self) -> datetime | None: + """Fetch certificate.""" + try: + timestamp = await get_cert_expiry_timestamp(self.hass, self.host, self.port) + except TemporaryFailure as err: + raise UpdateFailed(err.args[0]) from err + except ValidationFailure as err: + self.cert_error = err + self.is_cert_valid = False + _LOGGER.error("Certificate validation error: %s [%s]", self.host, err) + return None + + self.cert_error = None + self.is_cert_valid = True + return timestamp diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 52985da00146c5..f950fce6a68a9b 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -67,7 +67,7 @@ async def test_import_host_only(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -89,7 +89,7 @@ async def test_import_host_and_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -111,7 +111,7 @@ async def test_import_non_default_port(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( @@ -133,7 +133,7 @@ async def test_import_with_name(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): result = await hass.config_entries.flow.async_init( diff --git a/tests/components/cert_expiry/test_init.py b/tests/components/cert_expiry/test_init.py index 29fbf372ec42cc..6c1d593560e8c2 100644 --- a/tests/components/cert_expiry/test_init.py +++ b/tests/components/cert_expiry/test_init.py @@ -42,7 +42,7 @@ async def test_setup_with_config(hass: HomeAssistant) -> None: with patch( "homeassistant.components.cert_expiry.config_flow.get_cert_expiry_timestamp" ), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): await hass.async_block_till_done() @@ -63,7 +63,7 @@ async def test_update_unique_id(hass: HomeAssistant) -> None: assert not entry.unique_id with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=future_timestamp(1), ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -91,7 +91,7 @@ async def test_unload_config_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): assert await async_setup_component(hass, DOMAIN, {}) is True @@ -134,7 +134,7 @@ async def test_delay_load_during_startup(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): await hass.async_start() diff --git a/tests/components/cert_expiry/test_sensors.py b/tests/components/cert_expiry/test_sensors.py index e6a526c7c9e06d..48421f5c41f0ff 100644 --- a/tests/components/cert_expiry/test_sensors.py +++ b/tests/components/cert_expiry/test_sensors.py @@ -29,7 +29,7 @@ async def test_async_setup_entry(mock_now, hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -83,7 +83,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -99,7 +99,7 @@ async def test_update_sensor(hass: HomeAssistant) -> None: next_update = starting_time + timedelta(hours=24) with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=24)) @@ -127,7 +127,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: timestamp = future_timestamp(100) with patch("homeassistant.util.dt.utcnow", return_value=starting_time), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): entry.add_to_hass(hass) @@ -156,7 +156,7 @@ async def test_update_sensor_network_errors(hass: HomeAssistant) -> None: assert state.state == STATE_UNAVAILABLE with patch("homeassistant.util.dt.utcnow", return_value=next_update), patch( - "homeassistant.components.cert_expiry.get_cert_expiry_timestamp", + "homeassistant.components.cert_expiry.coordinator.get_cert_expiry_timestamp", return_value=timestamp, ): async_fire_time_changed(hass, utcnow() + timedelta(hours=48)) From 57337b5cee5a5ab076ab89ded23219b40497e6b8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:19:49 +0200 Subject: [PATCH 554/640] Move flipr coordinator to its own file (#100467) --- homeassistant/components/flipr/__init__.py | 47 +------------------ homeassistant/components/flipr/coordinator.py | 45 ++++++++++++++++++ tests/components/flipr/test_init.py | 2 +- 3 files changed, 48 insertions(+), 46 deletions(-) create mode 100644 homeassistant/components/flipr/coordinator.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 81c21a4aa99049..865aeaa2d28e6c 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -1,27 +1,16 @@ """The Flipr integration.""" -from datetime import timedelta -import logging - -from flipr_api import FliprAPIRestClient -from flipr_api.exceptions import FliprError - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import ( CoordinatorEntity, DataUpdateCoordinator, - UpdateFailed, ) from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER - -_LOGGER = logging.getLogger(__name__) - -SCAN_INTERVAL = timedelta(minutes=60) - +from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -49,38 +38,6 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: return unload_ok -class FliprDataUpdateCoordinator(DataUpdateCoordinator): - """Class to hold Flipr data retrieval.""" - - def __init__(self, hass, entry): - """Initialize.""" - username = entry.data[CONF_EMAIL] - password = entry.data[CONF_PASSWORD] - self.flipr_id = entry.data[CONF_FLIPR_ID] - - # Establishes the connection. - self.client = FliprAPIRestClient(username, password) - self.entry = entry - - super().__init__( - hass, - _LOGGER, - name=f"Flipr data measure for {self.flipr_id}", - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from API endpoint.""" - try: - data = await self.hass.async_add_executor_job( - self.client.get_pool_measure_latest, self.flipr_id - ) - except FliprError as error: - raise UpdateFailed(error) from error - - return data - - class FliprEntity(CoordinatorEntity): """Implements a common class elements representing the Flipr component.""" diff --git a/homeassistant/components/flipr/coordinator.py b/homeassistant/components/flipr/coordinator.py new file mode 100644 index 00000000000000..d51db645035c04 --- /dev/null +++ b/homeassistant/components/flipr/coordinator.py @@ -0,0 +1,45 @@ +"""DataUpdateCoordinator for flipr integration.""" +from datetime import timedelta +import logging + +from flipr_api import FliprAPIRestClient +from flipr_api.exceptions import FliprError + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_FLIPR_ID + +_LOGGER = logging.getLogger(__name__) + + +class FliprDataUpdateCoordinator(DataUpdateCoordinator): + """Class to hold Flipr data retrieval.""" + + def __init__(self, hass, entry): + """Initialize.""" + username = entry.data[CONF_EMAIL] + password = entry.data[CONF_PASSWORD] + self.flipr_id = entry.data[CONF_FLIPR_ID] + + # Establishes the connection. + self.client = FliprAPIRestClient(username, password) + self.entry = entry + + super().__init__( + hass, + _LOGGER, + name=f"Flipr data measure for {self.flipr_id}", + update_interval=timedelta(minutes=60), + ) + + async def _async_update_data(self): + """Fetch data from API endpoint.""" + try: + data = await self.hass.async_add_executor_job( + self.client.get_pool_measure_latest, self.flipr_id + ) + except FliprError as error: + raise UpdateFailed(error) from error + + return data diff --git a/tests/components/flipr/test_init.py b/tests/components/flipr/test_init.py index e9685bd6e0a941..c1c5c0086e7692 100644 --- a/tests/components/flipr/test_init.py +++ b/tests/components/flipr/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry(hass: HomeAssistant) -> None: unique_id="123456", ) entry.add_to_hass(hass) - with patch("homeassistant.components.flipr.FliprAPIRestClient"): + with patch("homeassistant.components.flipr.coordinator.FliprAPIRestClient"): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() await hass.config_entries.async_unload(entry.entry_id) From b5c6e8237471e9734ce4244986055035715c6cac Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:49:49 +0200 Subject: [PATCH 555/640] Move co2signal models to their own file (#100478) --- .../components/co2signal/__init__.py | 25 ++----------------- homeassistant/components/co2signal/models.py | 24 ++++++++++++++++++ 2 files changed, 26 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/co2signal/models.py diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 943fa13e240077..79c56ec63d4db9 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -4,7 +4,7 @@ from collections.abc import Mapping from datetime import timedelta import logging -from typing import Any, TypedDict, cast +from typing import Any, cast import CO2Signal @@ -16,33 +16,12 @@ from .const import CONF_COUNTRY_CODE, DOMAIN from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError +from .models import CO2SignalResponse PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) -class CO2SignalData(TypedDict): - """Data field.""" - - carbonIntensity: float - fossilFuelPercentage: float - - -class CO2SignalUnit(TypedDict): - """Unit field.""" - - carbonIntensity: str - - -class CO2SignalResponse(TypedDict): - """API response.""" - - status: str - countryCode: str - data: CO2SignalData - units: CO2SignalUnit - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up CO2 Signal from a config entry.""" coordinator = CO2SignalCoordinator(hass, entry) diff --git a/homeassistant/components/co2signal/models.py b/homeassistant/components/co2signal/models.py new file mode 100644 index 00000000000000..758bb15c5f0851 --- /dev/null +++ b/homeassistant/components/co2signal/models.py @@ -0,0 +1,24 @@ +"""Models to the co2signal integration.""" +from typing import TypedDict + + +class CO2SignalData(TypedDict): + """Data field.""" + + carbonIntensity: float + fossilFuelPercentage: float + + +class CO2SignalUnit(TypedDict): + """Unit field.""" + + carbonIntensity: str + + +class CO2SignalResponse(TypedDict): + """API response.""" + + status: str + countryCode: str + data: CO2SignalData + units: CO2SignalUnit From 16cc87bf45de5a39859d39f7744594602c16a6f8 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Sat, 16 Sep 2023 11:55:49 +0200 Subject: [PATCH 556/640] Move flipr base entity to its own file (#100481) * Move flipr base entity to its own file * Add forgotten __init__.py --- homeassistant/components/flipr/__init__.py | 33 ++----------------- .../components/flipr/binary_sensor.py | 2 +- homeassistant/components/flipr/entity.py | 32 ++++++++++++++++++ homeassistant/components/flipr/sensor.py | 2 +- 4 files changed, 36 insertions(+), 33 deletions(-) create mode 100644 homeassistant/components/flipr/entity.py diff --git a/homeassistant/components/flipr/__init__.py b/homeassistant/components/flipr/__init__.py index 865aeaa2d28e6c..e6d7cb1dd1751b 100644 --- a/homeassistant/components/flipr/__init__.py +++ b/homeassistant/components/flipr/__init__.py @@ -2,14 +2,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import ( - CoordinatorEntity, - DataUpdateCoordinator, -) - -from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER + +from .const import DOMAIN from .coordinator import FliprDataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -36,26 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FliprEntity(CoordinatorEntity): - """Implements a common class elements representing the Flipr component.""" - - _attr_attribution = ATTRIBUTION - _attr_has_entity_name = True - - def __init__( - self, coordinator: DataUpdateCoordinator, description: EntityDescription - ) -> None: - """Initialize Flipr sensor.""" - super().__init__(coordinator) - self.entity_description = description - if coordinator.config_entry: - flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] - self._attr_unique_id = f"{flipr_id}-{description.key}" - - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, flipr_id)}, - manufacturer=MANUFACTURER, - name=f"Flipr {flipr_id}", - ) diff --git a/homeassistant/components/flipr/binary_sensor.py b/homeassistant/components/flipr/binary_sensor.py index 0597145c2da1dc..677a282e8cb3ce 100644 --- a/homeassistant/components/flipr/binary_sensor.py +++ b/homeassistant/components/flipr/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity BINARY_SENSORS_TYPES: tuple[BinarySensorEntityDescription, ...] = ( BinarySensorEntityDescription( diff --git a/homeassistant/components/flipr/entity.py b/homeassistant/components/flipr/entity.py new file mode 100644 index 00000000000000..6166d727ac714e --- /dev/null +++ b/homeassistant/components/flipr/entity.py @@ -0,0 +1,32 @@ +"""Base entity for the flipr entity.""" +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) + +from .const import ATTRIBUTION, CONF_FLIPR_ID, DOMAIN, MANUFACTURER + + +class FliprEntity(CoordinatorEntity): + """Implements a common class elements representing the Flipr component.""" + + _attr_attribution = ATTRIBUTION + _attr_has_entity_name = True + + def __init__( + self, coordinator: DataUpdateCoordinator, description: EntityDescription + ) -> None: + """Initialize Flipr sensor.""" + super().__init__(coordinator) + self.entity_description = description + if coordinator.config_entry: + flipr_id = coordinator.config_entry.data[CONF_FLIPR_ID] + self._attr_unique_id = f"{flipr_id}-{description.key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, flipr_id)}, + manufacturer=MANUFACTURER, + name=f"Flipr {flipr_id}", + ) diff --git a/homeassistant/components/flipr/sensor.py b/homeassistant/components/flipr/sensor.py index 078e581edda489..a8618b2df879b2 100644 --- a/homeassistant/components/flipr/sensor.py +++ b/homeassistant/components/flipr/sensor.py @@ -12,8 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FliprEntity from .const import DOMAIN +from .entity import FliprEntity SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( SensorEntityDescription( From 30d604c8510bae333088155122621aa28b6af32e Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 13:46:11 +0200 Subject: [PATCH 557/640] Use central logger in Withings (#100406) --- homeassistant/components/withings/__init__.py | 9 ++++--- homeassistant/components/withings/api.py | 8 +++---- homeassistant/components/withings/common.py | 24 +++++++++---------- homeassistant/components/withings/const.py | 3 +++ 4 files changed, 21 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/withings/__init__.py b/homeassistant/components/withings/__init__.py index 589bfe79094a79..5e7337086392e0 100644 --- a/homeassistant/components/withings/__init__.py +++ b/homeassistant/components/withings/__init__.py @@ -34,13 +34,12 @@ from . import const from .common import ( - _LOGGER, async_get_data_manager, async_remove_data_manager, get_data_manager_by_webhook_id, json_message_response, ) -from .const import CONF_USE_WEBHOOK, CONFIG +from .const import CONF_USE_WEBHOOK, CONFIG, LOGGER DOMAIN = const.DOMAIN PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -92,7 +91,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: conf[CONF_CLIENT_SECRET], ), ) - _LOGGER.warning( + LOGGER.warning( "Configuration of Withings integration OAuth2 credentials in YAML " "is deprecated and will be removed in a future release; Your " "existing OAuth Application Credentials have been imported into " @@ -125,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: data_manager = await async_get_data_manager(hass, entry) - _LOGGER.debug("Confirming %s is authenticated to withings", entry.title) + LOGGER.debug("Confirming %s is authenticated to withings", entry.title) await data_manager.poll_data_update_coordinator.async_config_entry_first_refresh() webhook.async_register( @@ -205,7 +204,7 @@ async def async_webhook_handler( data_manager = get_data_manager_by_webhook_id(hass, webhook_id) if not data_manager: - _LOGGER.error( + LOGGER.error( ( "Webhook id %s not handled by data manager. This is a bug and should be" " reported" diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py index fff9767ebdafa7..3a81fb298eaca3 100644 --- a/homeassistant/components/withings/api.py +++ b/homeassistant/components/withings/api.py @@ -3,7 +3,6 @@ import asyncio from collections.abc import Iterable -import logging from typing import Any import arrow @@ -26,9 +25,8 @@ OAuth2Session, ) -from .const import LOG_NAMESPACE +from .const import LOGGER -_LOGGER = logging.getLogger(LOG_NAMESPACE) _RETRY_COEFFICIENT = 0.5 @@ -73,11 +71,11 @@ async def _do_retry(self, func, attempts=3) -> Any: """ exception = None for attempt in range(1, attempts + 1): - _LOGGER.debug("Attempt %s of %s", attempt, attempts) + LOGGER.debug("Attempt %s of %s", attempt, attempts) try: return await func() except Exception as exception1: # pylint: disable=broad-except - _LOGGER.debug( + LOGGER.debug( "Failed attempt %s of %s (%s)", attempt, attempts, exception1 ) # Make each backoff pause a little bit longer diff --git a/homeassistant/components/withings/common.py b/homeassistant/components/withings/common.py index 446fb4b58e5351..5f0090ad9a6cfd 100644 --- a/homeassistant/components/withings/common.py +++ b/homeassistant/components/withings/common.py @@ -8,7 +8,6 @@ from datetime import timedelta from enum import IntEnum, StrEnum from http import HTTPStatus -import logging import re from typing import Any @@ -35,9 +34,8 @@ from . import const from .api import ConfigEntryWithingsApi -from .const import Measurement +from .const import LOGGER, Measurement -_LOGGER = logging.getLogger(const.LOG_NAMESPACE) NOT_AUTHENTICATED_ERROR = re.compile( f"^{HTTPStatus.UNAUTHORIZED},.*", re.IGNORECASE, @@ -181,7 +179,7 @@ def __init__( self.subscription_update_coordinator = DataUpdateCoordinator( hass, - _LOGGER, + LOGGER, name="subscription_update_coordinator", update_interval=timedelta(minutes=120), update_method=self.async_subscribe_webhook, @@ -190,7 +188,7 @@ def __init__( dict[MeasureType, Any] | None ]( hass, - _LOGGER, + LOGGER, name="poll_data_update_coordinator", update_interval=timedelta(minutes=120) if self._webhook_config.enabled @@ -232,14 +230,14 @@ def async_stop_polling_webhook_subscriptions(self) -> None: async def async_subscribe_webhook(self) -> None: """Subscribe the webhook to withings data updates.""" - _LOGGER.debug("Configuring withings webhook") + LOGGER.debug("Configuring withings webhook") # On first startup, perform a fresh re-subscribe. Withings stops pushing data # if the webhook fails enough times but they don't remove the old subscription # config. This ensures the subscription is setup correctly and they start # pushing again. if self._subscribe_webhook_run_count == 0: - _LOGGER.debug("Refreshing withings webhook configs") + LOGGER.debug("Refreshing withings webhook configs") await self.async_unsubscribe_webhook() self._subscribe_webhook_run_count += 1 @@ -262,7 +260,7 @@ async def async_subscribe_webhook(self) -> None: # Subscribe to each one. for appli in to_add_applis: - _LOGGER.debug( + LOGGER.debug( "Subscribing %s for %s in %s seconds", self._webhook_config.url, appli, @@ -280,7 +278,7 @@ async def async_unsubscribe_webhook(self) -> None: # Revoke subscriptions. for profile in response.profiles: - _LOGGER.debug( + LOGGER.debug( "Unsubscribing %s for %s in %s seconds", profile.callbackurl, profile.appli, @@ -310,7 +308,7 @@ async def async_get_all_data(self) -> dict[MeasureType, Any] | None: async def async_get_measures(self) -> dict[Measurement, Any]: """Get the measures data.""" - _LOGGER.debug("Updating withings measures") + LOGGER.debug("Updating withings measures") now = dt_util.utcnow() startdate = now - datetime.timedelta(days=7) @@ -338,7 +336,7 @@ async def async_get_measures(self) -> dict[Measurement, Any]: async def async_get_sleep_summary(self) -> dict[Measurement, Any]: """Get the sleep summary data.""" - _LOGGER.debug("Updating withing sleep summary") + LOGGER.debug("Updating withing sleep summary") now = dt_util.now() yesterday = now - datetime.timedelta(days=1) yesterday_noon = dt_util.start_of_local_day(yesterday) + datetime.timedelta( @@ -419,7 +417,7 @@ def set_value(field: GetSleepSummaryField, func: Callable) -> None: async def async_webhook_data_updated(self, data_category: NotifyAppli) -> None: """Handle scenario when data is updated from a webook.""" - _LOGGER.debug("Withings webhook triggered") + LOGGER.debug("Withings webhook triggered") if data_category in { NotifyAppli.WEIGHT, NotifyAppli.CIRCULATORY, @@ -442,7 +440,7 @@ async def async_get_data_manager( config_entry_data = hass.data[const.DOMAIN][config_entry.entry_id] if const.DATA_MANAGER not in config_entry_data: - _LOGGER.debug( + LOGGER.debug( "Creating withings data manager for profile: %s", config_entry.title ) config_entry_data[const.DATA_MANAGER] = DataManager( diff --git a/homeassistant/components/withings/const.py b/homeassistant/components/withings/const.py index 926d29abe5ce55..545c7bfcb26a34 100644 --- a/homeassistant/components/withings/const.py +++ b/homeassistant/components/withings/const.py @@ -1,5 +1,6 @@ """Constants used by the Withings component.""" from enum import StrEnum +import logging DEFAULT_TITLE = "Withings" CONF_PROFILES = "profiles" @@ -13,6 +14,8 @@ PROFILE = "profile" PUSH_HANDLER = "push_handler" +LOGGER = logging.getLogger(__package__) + class Measurement(StrEnum): """Measurement supported by the withings integration.""" From 48dc81eff09c46780d5f57c76bd0c9d2bcfe9f49 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 13:49:37 +0200 Subject: [PATCH 558/640] Simplify code, due to better error catching in modbus. (#100483) --- homeassistant/components/modbus/binary_sensor.py | 5 +---- homeassistant/components/modbus/climate.py | 5 ----- homeassistant/components/modbus/cover.py | 5 ----- homeassistant/components/modbus/modbus.py | 2 +- 4 files changed, 2 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/modbus/binary_sensor.py b/homeassistant/components/modbus/binary_sensor.py index 3dabeee081c3b7..39174ae89311a1 100644 --- a/homeassistant/components/modbus/binary_sensor.py +++ b/homeassistant/components/modbus/binary_sensor.py @@ -122,10 +122,7 @@ async def async_update(self, now: datetime | None = None) -> None: self._result = result.bits else: self._result = result.registers - if len(self._result) >= 1: - self._attr_is_on = bool(self._result[0] & 1) - else: - self._attr_available = False + self._attr_is_on = bool(self._result[0] & 1) self.async_write_ha_state() if self._coordinator: diff --git a/homeassistant/components/modbus/climate.py b/homeassistant/components/modbus/climate.py index 3acf8d7ac296e2..df2983e9070f7a 100644 --- a/homeassistant/components/modbus/climate.py +++ b/homeassistant/components/modbus/climate.py @@ -247,10 +247,6 @@ async def async_update(self, now: datetime | None = None) -> None: # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True self._attr_target_temperature = await self._async_read_register( CALL_TYPE_REGISTER_HOLDING, self._target_temperature_register ) @@ -282,7 +278,6 @@ async def async_update(self, now: datetime | None = None) -> None: if onoff == 0: self._attr_hvac_mode = HVACMode.OFF - self._call_active = False self.async_write_ha_state() async def _async_read_register( diff --git a/homeassistant/components/modbus/cover.py b/homeassistant/components/modbus/cover.py index 3c4247c61fb1fe..27f9cb1fc1817a 100644 --- a/homeassistant/components/modbus/cover.py +++ b/homeassistant/components/modbus/cover.py @@ -138,14 +138,9 @@ async def async_update(self, now: datetime | None = None) -> None: """Update the state of the cover.""" # remark "now" is a dummy parameter to avoid problems with # async_track_time_interval - # do not allow multiple active calls to the same platform - if self._call_active: - return - self._call_active = True result = await self._hub.async_pb_call( self._slave, self._address, 1, self._input_type ) - self._call_active = False if result is None: if self._lazy_errors: self._lazy_errors -= 1 diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index 31179a2358301c..db8a4d47fdc477 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -261,7 +261,7 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: """Initialize the Modbus hub.""" if CONF_CLOSE_COMM_ON_ERROR in client_config: - async_create_issue( # pragma: no cover + async_create_issue( hass, DOMAIN, "deprecated_close_comm_config", From 568974fcc4d796c80d51b207960ea888891d28c4 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 14:00:22 +0200 Subject: [PATCH 559/640] Modbus 100% test coverage (again) (#100482) --- tests/components/modbus/conftest.py | 2 +- tests/components/modbus/test_init.py | 7 +++++++ tests/components/modbus/test_sensor.py | 20 ++++++++++++++++++++ 3 files changed, 28 insertions(+), 1 deletion(-) diff --git a/tests/components/modbus/conftest.py b/tests/components/modbus/conftest.py index 460b1eb5dd315d..d7e4556f746220 100644 --- a/tests/components/modbus/conftest.py +++ b/tests/components/modbus/conftest.py @@ -136,7 +136,7 @@ async def mock_pymodbus_exception_fixture(hass, do_exception, mock_modbus): @pytest.fixture(name="mock_pymodbus_return") async def mock_pymodbus_return_fixture(hass, register_words, mock_modbus): """Trigger update call with time_changed event.""" - read_result = ReadResult(register_words) + read_result = ReadResult(register_words) if register_words else None mock_modbus.read_coils.return_value = read_result mock_modbus.read_discrete_inputs.return_value = read_result mock_modbus.read_input_registers.return_value = read_result diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index f9c7fb42b2d8e7..5f8c0554e6d851 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -40,6 +40,7 @@ CALL_TYPE_WRITE_REGISTERS, CONF_BAUDRATE, CONF_BYTESIZE, + CONF_CLOSE_COMM_ON_ERROR, CONF_DATA_TYPE, CONF_DEVICE_ADDRESS, CONF_INPUT_TYPE, @@ -413,6 +414,12 @@ async def test_duplicate_entity_validator(do_config) -> None: @pytest.mark.parametrize( "do_config", [ + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_CLOSE_COMM_ON_ERROR: True, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, diff --git a/tests/components/modbus/test_sensor.py b/tests/components/modbus/test_sensor.py index 46c38873a93d03..0f79a125c86a2f 100644 --- a/tests/components/modbus/test_sensor.py +++ b/tests/components/modbus/test_sensor.py @@ -409,6 +409,17 @@ async def test_config_wrong_struct_sensor( False, "-1985229329", ), + ( + { + CONF_DATA_TYPE: DataType.INT32, + CONF_SCALE: 1, + CONF_OFFSET: 0, + CONF_PRECISION: 0, + }, + [0x89AB], + False, + STATE_UNAVAILABLE, + ), ( { CONF_DATA_TYPE: DataType.UINT32, @@ -751,6 +762,15 @@ async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: False, ["16909060", "67305985"], ), + ( + { + CONF_VIRTUAL_COUNT: 2, + CONF_UNIQUE_ID: SLAVE_UNIQUE_ID, + }, + [0x0102, 0x0304, 0x0403, 0x0201, 0x0403], + False, + [STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_UNKNOWN], + ), ( { CONF_SLAVE_COUNT: 3, From f99dedfb428b9fc77dd98b4cbf0ecefcf91812bf Mon Sep 17 00:00:00 2001 From: Ravaka Razafimanantsoa <3774520+SeraphicRav@users.noreply.github.com> Date: Sat, 16 Sep 2023 23:00:41 +0900 Subject: [PATCH 560/640] Add switchbot cloud integration (#99607) * Switches via API * Using external library * UT and checlist * Updating file .coveragerc * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Update homeassistant/components/switchbot_via_api/switch.py Co-authored-by: J. Nick Koston * Review fixes * Apply suggestions from code review Co-authored-by: J. Nick Koston * This base class shouldn't know about Remote * Fixing suggestion * Sometimes, the state from the API is not updated immediately * Review changes * Some review changes * Review changes * Review change: Adding type on commands * Parameterizing some tests * Review changes * Updating .coveragerc * Fixing error handling in coordinator * Review changes * Review changes * Adding switchbot brand * Apply suggestions from code review Co-authored-by: J. Nick Koston * Review changes * Adding strict typing * Removing log in constructor --------- Co-authored-by: J. Nick Koston --- .coveragerc | 3 + .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/switchbot.json | 5 + .../components/switchbot/manifest.json | 2 +- .../components/switchbot_cloud/__init__.py | 81 ++++++++++++++ .../components/switchbot_cloud/config_flow.py | 56 ++++++++++ .../components/switchbot_cloud/const.py | 7 ++ .../components/switchbot_cloud/coordinator.py | 50 +++++++++ .../components/switchbot_cloud/entity.py | 49 +++++++++ .../components/switchbot_cloud/manifest.json | 10 ++ .../components/switchbot_cloud/strings.json | 20 ++++ .../components/switchbot_cloud/switch.py | 82 ++++++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 17 ++- mypy.ini | 10 ++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/switchbot_cloud/__init__.py | 20 ++++ tests/components/switchbot_cloud/conftest.py | 15 +++ .../switchbot_cloud/test_config_flow.py | 90 ++++++++++++++++ tests/components/switchbot_cloud/test_init.py | 100 ++++++++++++++++++ 22 files changed, 623 insertions(+), 4 deletions(-) create mode 100644 homeassistant/brands/switchbot.json create mode 100644 homeassistant/components/switchbot_cloud/__init__.py create mode 100644 homeassistant/components/switchbot_cloud/config_flow.py create mode 100644 homeassistant/components/switchbot_cloud/const.py create mode 100644 homeassistant/components/switchbot_cloud/coordinator.py create mode 100644 homeassistant/components/switchbot_cloud/entity.py create mode 100644 homeassistant/components/switchbot_cloud/manifest.json create mode 100644 homeassistant/components/switchbot_cloud/strings.json create mode 100644 homeassistant/components/switchbot_cloud/switch.py create mode 100644 tests/components/switchbot_cloud/__init__.py create mode 100644 tests/components/switchbot_cloud/conftest.py create mode 100644 tests/components/switchbot_cloud/test_config_flow.py create mode 100644 tests/components/switchbot_cloud/test_init.py diff --git a/.coveragerc b/.coveragerc index ddde800cd77ea5..e226b22381b068 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1252,6 +1252,9 @@ omit = homeassistant/components/switchbot/sensor.py homeassistant/components/switchbot/switch.py homeassistant/components/switchbot/lock.py + homeassistant/components/switchbot_cloud/coordinator.py + homeassistant/components/switchbot_cloud/entity.py + homeassistant/components/switchbot_cloud/switch.py homeassistant/components/switchmate/switch.py homeassistant/components/syncthing/__init__.py homeassistant/components/syncthing/sensor.py diff --git a/.strict-typing b/.strict-typing index c1138119f5f34d..56c7bf248e18c4 100644 --- a/.strict-typing +++ b/.strict-typing @@ -318,6 +318,7 @@ homeassistant.components.sun.* homeassistant.components.surepetcare.* homeassistant.components.switch.* homeassistant.components.switchbee.* +homeassistant.components.switchbot_cloud.* homeassistant.components.switcher_kis.* homeassistant.components.synology_dsm.* homeassistant.components.systemmonitor.* diff --git a/CODEOWNERS b/CODEOWNERS index 7c96042caa392e..8453a4893fedd8 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1238,6 +1238,8 @@ build.json @home-assistant/supervisor /tests/components/switchbee/ @jafar-atili /homeassistant/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski /tests/components/switchbot/ @danielhiversen @RenierM26 @murtas @Eloston @dsypniewski +/homeassistant/components/switchbot_cloud/ @SeraphicRav +/tests/components/switchbot_cloud/ @SeraphicRav /homeassistant/components/switcher_kis/ @thecode /tests/components/switcher_kis/ @thecode /homeassistant/components/switchmate/ @danielhiversen @qiz-li diff --git a/homeassistant/brands/switchbot.json b/homeassistant/brands/switchbot.json new file mode 100644 index 00000000000000..0909b24a146990 --- /dev/null +++ b/homeassistant/brands/switchbot.json @@ -0,0 +1,5 @@ +{ + "domain": "switchbot", + "name": "SwitchBot", + "integrations": ["switchbot", "switchbot_cloud"] +} diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 2259a450559114..49a6af2b179a21 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -1,6 +1,6 @@ { "domain": "switchbot", - "name": "SwitchBot", + "name": "SwitchBot Bluetooth", "bluetooth": [ { "service_data_uuid": "00000d00-0000-1000-8000-00805f9b34fb", diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000000..cf711fcc4311ce --- /dev/null +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -0,0 +1,81 @@ +"""The SwitchBot via API integration.""" +from asyncio import gather +from dataclasses import dataclass +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.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + +_LOGGER = getLogger(__name__) +PLATFORMS: list[Platform] = [Platform.SWITCH] + + +@dataclass +class SwitchbotDevices: + """Switchbot devices data.""" + + switches: list[Device | Remote] + + +@dataclass +class SwitchbotCloudData: + """Data to use in platforms.""" + + api: SwitchBotAPI + devices: SwitchbotDevices + + +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] + secret = config.data[CONF_API_KEY] + + api = SwitchBotAPI(token=token, secret=secret) + try: + devices = await api.list_devices() + except InvalidAuth as ex: + _LOGGER.error( + "Invalid authentication while connecting to SwitchBot API: %s", ex + ) + return False + 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 + ] + hass.data.setdefault(DOMAIN, {}) + data = SwitchbotCloudData( + api=api, + devices=SwitchbotDevices( + switches=[ + (device, coordinator) + for device, coordinator in devices_and_coordinators + if isinstance(device, Device) + and device.device_type.startswith("Plug") + or isinstance(device, Remote) + ], + ), + ) + hass.data[DOMAIN][config.entry_id] = data + _LOGGER.debug("Switches: %s", data.devices.switches) + await hass.config_entries.async_forward_entry_setups(config, PLATFORMS) + await gather( + *[coordinator.async_refresh() for _, coordinator in devices_and_coordinators] + ) + 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/switchbot_cloud/config_flow.py b/homeassistant/components/switchbot_cloud/config_flow.py new file mode 100644 index 00000000000000..5c99567968c1e6 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/config_flow.py @@ -0,0 +1,56 @@ +"""Config flow for SwitchBot via API integration.""" + +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, InvalidAuth, SwitchBotAPI +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, ENTRY_TITLE + +_LOGGER = getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_API_KEY): str, + } +) + + +class SwitchBotCloudConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for SwitchBot via API.""" + + 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: + try: + await SwitchBotAPI( + token=user_input[CONF_API_TOKEN], secret=user_input[CONF_API_KEY] + ).list_devices() + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + user_input[CONF_API_TOKEN], raise_on_progress=False + ) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=ENTRY_TITLE, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/switchbot_cloud/const.py b/homeassistant/components/switchbot_cloud/const.py new file mode 100644 index 00000000000000..ef69c9c1d02d29 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/const.py @@ -0,0 +1,7 @@ +"""Constants for the SwitchBot Cloud integration.""" +from datetime import timedelta +from typing import Final + +DOMAIN: Final = "switchbot_cloud" +ENTRY_TITLE = "SwitchBot Cloud" +SCAN_INTERVAL = timedelta(seconds=600) diff --git a/homeassistant/components/switchbot_cloud/coordinator.py b/homeassistant/components/switchbot_cloud/coordinator.py new file mode 100644 index 00000000000000..92099ccde4337b --- /dev/null +++ b/homeassistant/components/switchbot_cloud/coordinator.py @@ -0,0 +1,50 @@ +"""SwitchBot Cloud coordinator.""" +from asyncio import timeout +from logging import getLogger +from typing import Any + +from switchbot_api import CannotConnect, Device, Remote, SwitchBotAPI + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN, SCAN_INTERVAL + +_LOGGER = getLogger(__name__) + +Status = dict[str, Any] | None + + +class SwitchBotCoordinator(DataUpdateCoordinator[Status]): + """SwitchBot Cloud coordinator.""" + + _api: SwitchBotAPI + _device_id: str + _should_poll = False + + def __init__( + self, hass: HomeAssistant, api: SwitchBotAPI, device: Device | Remote + ) -> None: + """Initialize SwitchBot Cloud.""" + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + self._api = api + self._device_id = device.device_id + self._should_poll = not isinstance(device, Remote) + + async def _async_update_data(self) -> Status: + """Fetch data from API endpoint.""" + if not self._should_poll: + return None + try: + _LOGGER.debug("Refreshing %s", self._device_id) + async with timeout(10): + status: Status = await self._api.get_status(self._device_id) + _LOGGER.debug("Refreshing %s with %s", self._device_id, status) + return status + except CannotConnect as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/switchbot_cloud/entity.py b/homeassistant/components/switchbot_cloud/entity.py new file mode 100644 index 00000000000000..5d0e2ff09c34c4 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/entity.py @@ -0,0 +1,49 @@ +"""Base class for SwitchBot via API entities.""" +from typing import Any + +from switchbot_api import Commands, Device, Remote, SwitchBotAPI + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import SwitchBotCoordinator + + +class SwitchBotCloudEntity(CoordinatorEntity[SwitchBotCoordinator]): + """Representation of a SwitchBot Cloud entity.""" + + _api: SwitchBotAPI + _switchbot_state: dict[str, Any] | None = None + _attr_has_entity_name = True + + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + ) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._api = api + self._attr_unique_id = device.device_id + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, device.device_id)}, + name=device.device_name, + manufacturer="SwitchBot", + model=device.device_type, + ) + + async def send_command( + self, + command: Commands, + command_type: str = "command", + parameters: dict | str = "default", + ) -> None: + """Send command to device.""" + await self._api.send_command( + self._attr_unique_id, + command, + command_type, + parameters, + ) diff --git a/homeassistant/components/switchbot_cloud/manifest.json b/homeassistant/components/switchbot_cloud/manifest.json new file mode 100644 index 00000000000000..0451217ca5f34d --- /dev/null +++ b/homeassistant/components/switchbot_cloud/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "switchbot_cloud", + "name": "SwitchBot Cloud", + "codeowners": ["@SeraphicRav"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/switchbot_cloud", + "iot_class": "cloud_polling", + "loggers": ["switchbot-api"], + "requirements": ["switchbot-api==1.1.0"] +} diff --git a/homeassistant/components/switchbot_cloud/strings.json b/homeassistant/components/switchbot_cloud/strings.json new file mode 100644 index 00000000000000..11e92e6dfa38f9 --- /dev/null +++ b/homeassistant/components/switchbot_cloud/strings.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py new file mode 100644 index 00000000000000..c63b1713b8de6c --- /dev/null +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -0,0 +1,82 @@ +"""Support for SwitchBot switch.""" +from typing import Any + +from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI + +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 +from .coordinator import SwitchBotCoordinator +from .entity import SwitchBotCloudEntity + + +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( + _async_make_entity(data.api, device, coordinator) + for device, coordinator in data.devices.switches + ) + + +class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): + """Representation of a SwitchBot switch.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + _attr_name = None + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self.send_command(CommonCommands.ON) + self._attr_is_on = True + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self.send_command(CommonCommands.OFF) + self._attr_is_on = False + self.async_write_ha_state() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + if not self.coordinator.data: + return + self._attr_is_on = self.coordinator.data.get("power") == PowerState.ON.value + self.async_write_ha_state() + + +class SwitchBotCloudRemoteSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot switch provider by a remote.""" + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + +class SwitchBotCloudPlugSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot plug switch.""" + + _attr_device_class = SwitchDeviceClass.OUTLET + + +@callback +def _async_make_entity( + api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator +) -> SwitchBotCloudSwitch: + """Make a SwitchBotCloudSwitch or SwitchBotCloudRemoteSwitch.""" + if isinstance(device, Remote): + return SwitchBotCloudRemoteSwitch(api, device, coordinator) + if "Plug" in device.device_type: + return SwitchBotCloudPlugSwitch(api, device, coordinator) + raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 98935086b88e49..229682eff1d645 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -455,6 +455,7 @@ "surepetcare", "switchbee", "switchbot", + "switchbot_cloud", "switcher_kis", "syncthing", "syncthru", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 779ee92e9fe37f..a65239316ed098 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5514,9 +5514,20 @@ }, "switchbot": { "name": "SwitchBot", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "integrations": { + "switchbot": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "SwitchBot Bluetooth" + }, + "switchbot_cloud": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling", + "name": "SwitchBot Cloud" + } + } }, "switcher_kis": { "name": "Switcher", diff --git a/mypy.ini b/mypy.ini index 3d6e4e1b2b6ca8..d2c2a66d738aa6 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2943,6 +2943,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.switchbot_cloud.*] +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.switcher_kis.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index a87177e296df1c..52341321ced1c6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2502,6 +2502,9 @@ surepy==0.8.0 # homeassistant.components.swiss_hydrological_data swisshydrodata==0.1.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.synology_srm synology-srm==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2d44de0327568..1de5c8ae574d15 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1847,6 +1847,9 @@ sunwatcher==0.2.1 # homeassistant.components.surepetcare surepy==0.8.0 +# homeassistant.components.switchbot_cloud +switchbot-api==1.1.0 + # homeassistant.components.system_bridge systembridgeconnector==3.8.2 diff --git a/tests/components/switchbot_cloud/__init__.py b/tests/components/switchbot_cloud/__init__.py new file mode 100644 index 00000000000000..72d23c837ac7c6 --- /dev/null +++ b/tests/components/switchbot_cloud/__init__.py @@ -0,0 +1,20 @@ +"""Tests for the SwitchBot Cloud integration.""" +from homeassistant.components.switchbot_cloud.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +def configure_integration(hass: HomeAssistant) -> MockConfigEntry: + """Configure the integration.""" + config = { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-api-key", + } + entry = MockConfigEntry( + domain=DOMAIN, data=config, entry_id="123456", unique_id="123456" + ) + entry.add_to_hass(hass) + + return entry diff --git a/tests/components/switchbot_cloud/conftest.py b/tests/components/switchbot_cloud/conftest.py new file mode 100644 index 00000000000000..b96d76387975ad --- /dev/null +++ b/tests/components/switchbot_cloud/conftest.py @@ -0,0 +1,15 @@ +"""Common fixtures for the SwitchBot via API tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.switchbot_cloud.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/switchbot_cloud/test_config_flow.py b/tests/components/switchbot_cloud/test_config_flow.py new file mode 100644 index 00000000000000..6fdf8fecdb7119 --- /dev/null +++ b/tests/components/switchbot_cloud/test_config_flow.py @@ -0,0 +1,90 @@ +"""Test the SwitchBot via API config flow.""" +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.switchbot_cloud.config_flow import ( + CannotConnect, + InvalidAuth, +) +from homeassistant.components.switchbot_cloud.const import DOMAIN, ENTRY_TITLE +from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +async def _fill_out_form_and_assert_entry_created( + hass: HomeAssistant, flow_id: str, mock_setup_entry: AsyncMock +) -> None: + """Util function to fill out a form and assert that a config entry is created.""" + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + return_value=[], + ): + result_configure = await hass.config_entries.flow.async_configure( + flow_id, + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + await hass.async_block_till_done() + + assert result_configure["type"] == FlowResultType.CREATE_ENTRY + assert result_configure["title"] == ENTRY_TITLE + assert result_configure["data"] == { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + } + mock_setup_entry.assert_called_once() + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the form.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result_init["type"] == FlowResultType.FORM + assert not result_init["errors"] + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) + + +@pytest.mark.parametrize( + ("error", "message"), + [ + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_form_fails( + hass: HomeAssistant, error: Exception, message: str, mock_setup_entry: AsyncMock +) -> None: + """Test we handle error cases.""" + result_init = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.switchbot_cloud.config_flow.SwitchBotAPI.list_devices", + side_effect=error, + ): + result_configure = await hass.config_entries.flow.async_configure( + result_init["flow_id"], + { + CONF_API_TOKEN: "test-token", + CONF_API_KEY: "test-secret-key", + }, + ) + + assert result_configure["type"] == FlowResultType.FORM + assert result_configure["errors"] == {"base": message} + await hass.async_block_till_done() + + await _fill_out_form_and_assert_entry_created( + hass, result_init["flow_id"], mock_setup_entry + ) diff --git a/tests/components/switchbot_cloud/test_init.py b/tests/components/switchbot_cloud/test_init.py new file mode 100644 index 00000000000000..48f0021bdb46d2 --- /dev/null +++ b/tests/components/switchbot_cloud/test_init.py @@ -0,0 +1,100 @@ +"""Tests for the SwitchBot Cloud integration init.""" + +from unittest.mock import patch + +import pytest +from switchbot_api import CannotConnect, Device, InvalidAuth, PowerState + +from homeassistant.components.switchbot_cloud import SwitchBotAPI +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_START +from homeassistant.core import HomeAssistant + +from . import configure_integration + + +@pytest.fixture +def mock_list_devices(): + """Mock list_devices.""" + with patch.object(SwitchBotAPI, "list_devices") as mock_list_devices: + yield mock_list_devices + + +@pytest.fixture +def mock_get_status(): + """Mock get_status.""" + with patch.object(SwitchBotAPI, "get_status") as mock_get_status: + yield mock_get_status + + +async def test_setup_entry_success( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test successful setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.return_value = {"power": PowerState.ON.value} + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() + + +@pytest.mark.parametrize( + ("error", "state"), + [ + (InvalidAuth, ConfigEntryState.SETUP_ERROR), + (CannotConnect, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_entry_fails_when_listing_devices( + hass: HomeAssistant, + error: Exception, + state: ConfigEntryState, + mock_list_devices, + mock_get_status, +) -> None: + """Test error handling when list_devices in setup of entry.""" + mock_list_devices.side_effect = error + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == state + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_not_called() + + +async def test_setup_entry_fails_when_refreshing( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test error handling in get_status in setup of entry.""" + mock_list_devices.return_value = [ + Device( + deviceId="test-id", + deviceName="test-name", + deviceType="Plug", + hubDeviceId="test-hub-id", + ) + ] + mock_get_status.side_effect = CannotConnect + entry = configure_integration(hass) + await hass.config_entries.async_setup(entry.entry_id) + assert entry.state == ConfigEntryState.LOADED + + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + mock_list_devices.assert_called_once() + mock_get_status.assert_called() From 7b71d276379aa221690296fae9d29ea194a70ecb Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 16:20:24 +0200 Subject: [PATCH 561/640] Pass function correctly to Withings API (#100391) * Pass function correctly to Withings API * Add more typing --- homeassistant/components/withings/api.py | 35 ++++++++++++++---------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/withings/api.py b/homeassistant/components/withings/api.py index 3a81fb298eaca3..f9739d3fb6f1ad 100644 --- a/homeassistant/components/withings/api.py +++ b/homeassistant/components/withings/api.py @@ -2,7 +2,7 @@ from __future__ import annotations import asyncio -from collections.abc import Iterable +from collections.abc import Awaitable, Callable, Iterable from typing import Any import arrow @@ -63,7 +63,7 @@ def _request( ) return response.json() - async def _do_retry(self, func, attempts=3) -> Any: + async def _do_retry(self, func: Callable[[], Awaitable[Any]], attempts=3) -> Any: """Retry a function call. Withings' API occasionally and incorrectly throws errors. @@ -97,8 +97,8 @@ async def async_measure_get_meas( ) -> MeasureGetMeasResponse: """Get measurements.""" - return await self._do_retry( - await self._hass.async_add_executor_job( + async def call_super() -> MeasureGetMeasResponse: + return await self._hass.async_add_executor_job( self.measure_get_meas, meastype, category, @@ -107,7 +107,8 @@ async def async_measure_get_meas( offset, lastupdate, ) - ) + + return await self._do_retry(call_super) async def async_sleep_get_summary( self, @@ -119,8 +120,8 @@ async def async_sleep_get_summary( ) -> SleepGetSummaryResponse: """Get sleep data.""" - return await self._do_retry( - await self._hass.async_add_executor_job( + async def call_super() -> SleepGetSummaryResponse: + return await self._hass.async_add_executor_job( self.sleep_get_summary, data_fields, startdateymd, @@ -128,16 +129,18 @@ async def async_sleep_get_summary( offset, lastupdate, ) - ) + + return await self._do_retry(call_super) async def async_notify_list( self, appli: NotifyAppli | None = None ) -> NotifyListResponse: """List webhooks.""" - return await self._do_retry( - await self._hass.async_add_executor_job(self.notify_list, appli) - ) + 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, @@ -147,19 +150,21 @@ async def async_notify_subscribe( ) -> None: """Subscribe to webhook.""" - return await self._do_retry( + 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.""" - return await self._do_retry( + async def call_super() -> None: await self._hass.async_add_executor_job( self.notify_revoke, callbackurl, appli ) - ) + + await self._do_retry(call_super) From 8a98a0e830e9381d9959d8b7b7e732b27f6be5ff Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Sat, 16 Sep 2023 09:57:43 -0500 Subject: [PATCH 562/640] Avoid writing unifiprotect state when nothing has changed (#100439) --- .../components/unifiprotect/binary_sensor.py | 20 ++++++++ .../components/unifiprotect/button.py | 14 +++++ .../components/unifiprotect/media_player.py | 20 ++++++++ .../components/unifiprotect/number.py | 18 +++++++ .../components/unifiprotect/select.py | 51 ++++++++++++------- .../components/unifiprotect/sensor.py | 38 +++++++++++++- .../components/unifiprotect/switch.py | 27 +++++++--- 7 files changed, 163 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/unifiprotect/binary_sensor.py b/homeassistant/components/unifiprotect/binary_sensor.py index 668fe479e1f15d..8f8bcab8edeba4 100644 --- a/homeassistant/components/unifiprotect/binary_sensor.py +++ b/homeassistant/components/unifiprotect/binary_sensor.py @@ -621,3 +621,23 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: if not is_on: self._event = None self._attr_extra_state_attributes = {} + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on, _attr_extra_state_attributes, and available are ever + updated for these entities, and since the websocket update for the + device will trigger an update for all entities connected to the device, + we want to avoid writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + previous_extra_state_attributes = self._attr_extra_state_attributes + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_extra_state_attributes != previous_extra_state_attributes + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/button.py b/homeassistant/components/unifiprotect/button.py index 3306743b7072af..bc93c1568662b7 100644 --- a/homeassistant/components/unifiprotect/button.py +++ b/homeassistant/components/unifiprotect/button.py @@ -193,3 +193,17 @@ async def async_press(self) -> None: if self.entity_description.ufp_press is not None: await getattr(self.device, self.entity_description.ufp_press)() + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only available is updated for these entities, and since the websocket + update for the device will trigger an update for all entities connected + to the device, we want to avoid writing state unless something has + actually changed. + """ + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if self._attr_available != previous_available: + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/media_player.py b/homeassistant/components/unifiprotect/media_player.py index c3f4e58e2471e8..df5ea40d4a912e 100644 --- a/homeassistant/components/unifiprotect/media_player.py +++ b/homeassistant/components/unifiprotect/media_player.py @@ -115,6 +115,26 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: ) self._attr_available = is_connected and updated_device.feature_flags.has_speaker + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the state, volume, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_state = self._attr_state + previous_available = self._attr_available + previous_volume_level = self._attr_volume_level + self._async_update_device_from_protect(device) + if ( + self._attr_state != previous_state + or self._attr_volume_level != previous_volume_level + or self._attr_available != previous_available + ): + self.async_write_ha_state() + async def async_set_volume_level(self, volume: float) -> None: """Set volume level, range 0..1.""" diff --git a/homeassistant/components/unifiprotect/number.py b/homeassistant/components/unifiprotect/number.py index 247e401b2ca14b..08bc9f385279bd 100644 --- a/homeassistant/components/unifiprotect/number.py +++ b/homeassistant/components/unifiprotect/number.py @@ -268,3 +268,21 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: async def async_set_native_value(self, value: float) -> None: """Set new value.""" await self.entity_description.ufp_set(self.device, value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/select.py b/homeassistant/components/unifiprotect/select.py index 26a03fb7967ced..7605be17fc93ea 100644 --- a/homeassistant/components/unifiprotect/select.py +++ b/homeassistant/components/unifiprotect/select.py @@ -349,9 +349,9 @@ def __init__( description: ProtectSelectEntityDescription, ) -> None: """Initialize the unifi protect select entity.""" + self._async_set_options(data, description) super().__init__(data, device, description) self._attr_name = f"{self.device.display_name} {self.entity_description.name}" - self._async_set_options() @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: @@ -366,31 +366,28 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: _LOGGER.debug( "Updating dynamic select options for %s", entity_description.name ) - self._async_set_options() + self._async_set_options(self.data, entity_description) + if (unifi_value := entity_description.get_ufp_value(device)) is None: + unifi_value = TYPE_EMPTY_VALUE + self._attr_current_option = self._unifi_to_hass_options.get( + unifi_value, unifi_value + ) @callback - def _async_set_options(self) -> None: + def _async_set_options( + self, data: ProtectData, description: ProtectSelectEntityDescription + ) -> None: """Set options attributes from UniFi Protect device.""" - - if self.entity_description.ufp_options is not None: - options = self.entity_description.ufp_options + if (ufp_options := description.ufp_options) is not None: + options = ufp_options else: - assert self.entity_description.ufp_options_fn is not None - options = self.entity_description.ufp_options_fn(self.data.api) + assert description.ufp_options_fn is not None + options = description.ufp_options_fn(data.api) self._attr_options = [item["name"] for item in options] self._hass_to_unifi_options = {item["name"]: item["id"] for item in options} self._unifi_to_hass_options = {item["id"]: item["name"] for item in options} - @property - def current_option(self) -> str: - """Return the current selected option.""" - - unifi_value = self.entity_description.get_ufp_value(self.device) - if unifi_value is None: - unifi_value = TYPE_EMPTY_VALUE - return self._unifi_to_hass_options.get(unifi_value, unifi_value) - async def async_select_option(self, option: str) -> None: """Change the Select Entity Option.""" @@ -404,3 +401,23 @@ async def async_select_option(self, option: str) -> None: if self.entity_description.ufp_enum_type is not None: unifi_value = self.entity_description.ufp_enum_type(unifi_value) await self.entity_description.ufp_set(self.device, unifi_value) + + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the options, option, and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_option = self._attr_current_option + previous_options = self._attr_options + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_current_option != previous_option + or self._attr_options != previous_options + or self._attr_available != previous_available + ): + self.async_write_ha_state() diff --git a/homeassistant/components/unifiprotect/sensor.py b/homeassistant/components/unifiprotect/sensor.py index d842b13b0151d3..756da49eb4d054 100644 --- a/homeassistant/components/unifiprotect/sensor.py +++ b/homeassistant/components/unifiprotect/sensor.py @@ -710,22 +710,56 @@ class ProtectDeviceSensor(ProtectDeviceEntity, SensorEntity): entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSensor(ProtectNVREntity, SensorEntity): """A Ubiquiti UniFi Protect Sensor.""" entity_description: ProtectSensorEntityDescription - @callback def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: super()._async_update_device_from_protect(device) self._attr_native_value = self.entity_description.get_ufp_value(self.device) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the native value and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_value = self._attr_native_value + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_native_value != previous_value + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectEventSensor(EventEntityMixin, SensorEntity): """A UniFi Protect Device Sensor with access tokens.""" diff --git a/homeassistant/components/unifiprotect/switch.py b/homeassistant/components/unifiprotect/switch.py index ea2d8256cbe0d5..f1e6185b01024d 100644 --- a/homeassistant/components/unifiprotect/switch.py +++ b/homeassistant/components/unifiprotect/switch.py @@ -420,21 +420,36 @@ def __init__( self._attr_name = f"{self.device.display_name} {self.entity_description.name}" self._switch_type = self.entity_description.key - @property - def is_on(self) -> bool: - """Return true if device is on.""" - return self.entity_description.get_ufp_value(self.device) is True + def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: + super()._async_update_device_from_protect(device) + self._attr_is_on = self.entity_description.get_ufp_value(self.device) is True async def async_turn_on(self, **kwargs: Any) -> None: """Turn the device on.""" - await self.entity_description.ufp_set(self.device, True) async def async_turn_off(self, **kwargs: Any) -> None: """Turn the device off.""" - await self.entity_description.ufp_set(self.device, False) + @callback + def _async_updated_event(self, device: ProtectModelWithId) -> None: + """Call back for incoming data that only writes when state has changed. + + Only the is_on and available are ever updated for these + entities, and since the websocket update for the device will trigger + an update for all entities connected to the device, we want to avoid + writing state unless something has actually changed. + """ + previous_is_on = self._attr_is_on + previous_available = self._attr_available + self._async_update_device_from_protect(device) + if ( + self._attr_is_on != previous_is_on + or self._attr_available != previous_available + ): + self.async_write_ha_state() + class ProtectNVRSwitch(ProtectNVREntity, SwitchEntity): """A UniFi Protect NVR Switch.""" From c8265a86b26b075d98ec7962851a6ff10e7f308f Mon Sep 17 00:00:00 2001 From: Kevin <36297312+kevin-kraus@users.noreply.github.com> Date: Sat, 16 Sep 2023 17:12:00 +0200 Subject: [PATCH 563/640] Bump python-androidtv to 0.0.72 (#100441) Co-authored-by: J. Nick Koston --- homeassistant/components/androidtv/manifest.json | 4 ++-- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/androidtv/manifest.json b/homeassistant/components/androidtv/manifest.json index f782db79879ca0..b8c020e6e1e95d 100644 --- a/homeassistant/components/androidtv/manifest.json +++ b/homeassistant/components/androidtv/manifest.json @@ -8,8 +8,8 @@ "iot_class": "local_polling", "loggers": ["adb_shell", "androidtv", "pure_python_adb"], "requirements": [ - "adb-shell[async]==0.4.3", - "androidtv[async]==0.0.70", + "adb-shell[async]==0.4.4", + "androidtv[async]==0.0.72", "pure-python-adb[async]==0.3.0.dev0" ] } diff --git a/requirements_all.txt b/requirements_all.txt index 52341321ced1c6..050cd284489896 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -147,7 +147,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -406,7 +406,7 @@ amberelectric==1.0.4 amcrest==1.9.8 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1de5c8ae574d15..91ac52958476b5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -128,7 +128,7 @@ accuweather==1.0.0 adax==0.2.0 # homeassistant.components.androidtv -adb-shell[async]==0.4.3 +adb-shell[async]==0.4.4 # homeassistant.components.alarmdecoder adext==0.4.2 @@ -375,7 +375,7 @@ airtouch4pyapi==1.0.5 amberelectric==1.0.4 # homeassistant.components.androidtv -androidtv[async]==0.0.70 +androidtv[async]==0.0.72 # homeassistant.components.androidtv_remote androidtvremote2==0.0.14 From 01ecef7f05b01883eec3af94cd559fb7910fc916 Mon Sep 17 00:00:00 2001 From: "J.P. Krauss" Date: Sat, 16 Sep 2023 09:16:15 -0700 Subject: [PATCH 564/640] Fix error is measurement is not sent by AirNow (#100477) --- homeassistant/components/airnow/sensor.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py index 09393741d636e6..c83232c273a78c 100644 --- a/homeassistant/components/airnow/sensor.py +++ b/homeassistant/components/airnow/sensor.py @@ -58,6 +58,16 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi """Describes Airnow sensor entity.""" +def station_extra_attrs(data: dict[str, Any]) -> dict[str, Any]: + """Process extra attributes for station location (if available).""" + if ATTR_API_STATION in data: + return { + "lat": data.get(ATTR_API_STATION_LATITUDE), + "long": data.get(ATTR_API_STATION_LONGITUDE), + } + return {} + + SENSOR_TYPES: tuple[AirNowEntityDescription, ...] = ( AirNowEntityDescription( key=ATTR_API_AQI, @@ -93,10 +103,7 @@ class AirNowEntityDescription(SensorEntityDescription, AirNowEntityDescriptionMi translation_key="station", icon="mdi:blur", value_fn=lambda data: data.get(ATTR_API_STATION), - extra_state_attributes_fn=lambda data: { - "lat": data[ATTR_API_STATION_LATITUDE], - "long": data[ATTR_API_STATION_LONGITUDE], - }, + extra_state_attributes_fn=station_extra_attrs, ), ) From f715f5c76fb892305fe5ee33fa2f8667ee2bf571 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 16 Sep 2023 18:48:41 +0200 Subject: [PATCH 565/640] Only get meteo france alert coordinator if it exists (#100493) Only get meteo france coordinator if it exists --- homeassistant/components/meteo_france/sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/meteo_france/sensor.py b/homeassistant/components/meteo_france/sensor.py index 98cb4665614f59..dd8fd4af83b0c2 100644 --- a/homeassistant/components/meteo_france/sensor.py +++ b/homeassistant/components/meteo_france/sensor.py @@ -196,9 +196,9 @@ async def async_setup_entry( data = hass.data[DOMAIN][entry.entry_id] coordinator_forecast: DataUpdateCoordinator[Forecast] = data[COORDINATOR_FORECAST] coordinator_rain: DataUpdateCoordinator[Rain] | None = data[COORDINATOR_RAIN] - coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data[ + coordinator_alert: DataUpdateCoordinator[CurrentPhenomenons] | None = data.get( COORDINATOR_ALERT - ] + ) entities: list[MeteoFranceSensor[Any]] = [ MeteoFranceSensor(coordinator_forecast, description) From 81af45347f6b8eab93f4037fe11bd7d5ea3bba85 Mon Sep 17 00:00:00 2001 From: Jieyu Yan Date: Sat, 16 Sep 2023 11:58:00 -0700 Subject: [PATCH 566/640] Add fan modes in Lyric integration (#100420) * Add fan modes in Lyric integration * add fan_mode only when available * move supported_features to init * mapped fan_modes to built-in modes * log KeyError for fan_modes --- homeassistant/components/lyric/__init__.py | 1 + homeassistant/components/lyric/climate.py | 72 +++++++++++++++++++--- 2 files changed, 66 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/lyric/__init__.py b/homeassistant/components/lyric/__init__.py index 3e83fedb72a5fb..d048b31d0b0619 100644 --- a/homeassistant/components/lyric/__init__.py +++ b/homeassistant/components/lyric/__init__.py @@ -133,6 +133,7 @@ def __init__( self._location = location self._mac_id = device.macID self._update_thermostat = coordinator.data.update_thermostat + self._update_fan = coordinator.data.update_fan @property def unique_id(self) -> str: diff --git a/homeassistant/components/lyric/climate.py b/homeassistant/components/lyric/climate.py index 1522f167a4a970..d0bad55ff14cf6 100644 --- a/homeassistant/components/lyric/climate.py +++ b/homeassistant/components/lyric/climate.py @@ -14,6 +14,9 @@ from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + FAN_AUTO, + FAN_DIFFUSE, + FAN_ON, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -67,6 +70,10 @@ LYRIC_HVAC_MODE_COOL = "Cool" LYRIC_HVAC_MODE_HEAT_COOL = "Auto" +LYRIC_FAN_MODE_ON = "On" +LYRIC_FAN_MODE_AUTO = "Auto" +LYRIC_FAN_MODE_DIFFUSE = "Circulate" + LYRIC_HVAC_MODES = { HVACMode.OFF: LYRIC_HVAC_MODE_OFF, HVACMode.HEAT: LYRIC_HVAC_MODE_HEAT, @@ -81,6 +88,18 @@ LYRIC_HVAC_MODE_HEAT_COOL: HVACMode.HEAT_COOL, } +LYRIC_FAN_MODES = { + FAN_ON: LYRIC_FAN_MODE_ON, + FAN_AUTO: LYRIC_FAN_MODE_AUTO, + FAN_DIFFUSE: LYRIC_FAN_MODE_DIFFUSE, +} + +FAN_MODES = { + LYRIC_FAN_MODE_ON: FAN_ON, + LYRIC_FAN_MODE_AUTO: FAN_AUTO, + LYRIC_FAN_MODE_DIFFUSE: FAN_DIFFUSE, +} + HVAC_ACTIONS = { LYRIC_HVAC_ACTION_OFF: HVACAction.OFF, LYRIC_HVAC_ACTION_HEAT: HVACAction.HEATING, @@ -179,6 +198,25 @@ def __init__( ): self._attr_hvac_modes.append(HVACMode.HEAT_COOL) + # Setup supported features + if device.changeableValues.thermostatSetpointStatus: + self._attr_supported_features = SUPPORT_FLAGS_LCC + else: + self._attr_supported_features = SUPPORT_FLAGS_TCC + + # Setup supported fan modes + if device_fan_modes := device.settings.attributes.get("fan", {}).get( + "allowedModes" + ): + self._attr_fan_modes = [ + FAN_MODES[device_fan_mode] + for device_fan_mode in device_fan_modes + if device_fan_mode in FAN_MODES + ] + self._attr_supported_features = ( + self._attr_supported_features | ClimateEntityFeature.FAN_MODE + ) + super().__init__( coordinator, location, @@ -187,13 +225,6 @@ def __init__( ) self.entity_description = description - @property - def supported_features(self) -> ClimateEntityFeature: - """Return the list of supported features.""" - if self.device.changeableValues.thermostatSetpointStatus: - return SUPPORT_FLAGS_LCC - return SUPPORT_FLAGS_TCC - @property def current_temperature(self) -> float | None: """Return the current temperature.""" @@ -268,6 +299,16 @@ def max_temp(self) -> float: return device.maxHeatSetpoint return device.maxCoolSetpoint + @property + def fan_mode(self) -> str | None: + """Return current fan mode.""" + device = self.device + return FAN_MODES.get( + device.settings.attributes.get("fan", {}) + .get("changeableValues", {}) + .get("mode") + ) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if self.hvac_mode == HVACMode.OFF: @@ -390,3 +431,20 @@ async def async_set_hold_time(self, time_period: str) -> None: except LYRIC_EXCEPTIONS as exception: _LOGGER.error(exception) await self.coordinator.async_refresh() + + async def async_set_fan_mode(self, fan_mode: str) -> None: + """Set fan mode.""" + _LOGGER.debug("Set fan mode: %s", fan_mode) + try: + _LOGGER.debug("Fan mode passed to lyric: %s", LYRIC_FAN_MODES[fan_mode]) + await self._update_fan( + self.location, self.device, mode=LYRIC_FAN_MODES[fan_mode] + ) + except LYRIC_EXCEPTIONS as exception: + _LOGGER.error(exception) + except KeyError: + _LOGGER.error( + "The fan mode requested does not have a corresponding mode in lyric: %s", + fan_mode, + ) + await self.coordinator.async_refresh() From 9931f45532586b54519377d389859483897c83d7 Mon Sep 17 00:00:00 2001 From: jan iversen Date: Sat, 16 Sep 2023 21:14:52 +0200 Subject: [PATCH 567/640] Deprecate modbus parameter retry_on_empty (#100292) --- homeassistant/components/modbus/__init__.py | 2 +- homeassistant/components/modbus/modbus.py | 22 ++++++++++++++++++-- homeassistant/components/modbus/strings.json | 4 ++++ tests/components/modbus/test_init.py | 7 +++++++ 4 files changed, 32 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 875669e6dd7fce..85fba66b68a1c0 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -344,7 +344,7 @@ vol.Optional(CONF_CLOSE_COMM_ON_ERROR): cv.boolean, vol.Optional(CONF_DELAY, default=0): cv.positive_int, vol.Optional(CONF_RETRIES, default=3): cv.positive_int, - vol.Optional(CONF_RETRY_ON_EMPTY, default=False): cv.boolean, + vol.Optional(CONF_RETRY_ON_EMPTY): cv.boolean, vol.Optional(CONF_MSG_WAIT): cv.positive_int, vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [BINARY_SENSOR_SCHEMA] diff --git a/homeassistant/components/modbus/modbus.py b/homeassistant/components/modbus/modbus.py index db8a4d47fdc477..4ef205aace3363 100644 --- a/homeassistant/components/modbus/modbus.py +++ b/homeassistant/components/modbus/modbus.py @@ -276,7 +276,25 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: }, ) _LOGGER.warning( - "`close_comm_on_error`: is deprecated and will be remove in version 2024.4" + "`close_comm_on_error`: is deprecated and will be removed in version 2024.4" + ) + if CONF_RETRY_ON_EMPTY in client_config: + async_create_issue( + hass, + DOMAIN, + "deprecated_retry_on_empty", + breaks_in_ha_version="2024.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_retry_on_empty", + translation_placeholders={ + "config_key": "retry_on_empty", + "integration": DOMAIN, + "url": "https://www.home-assistant.io/integrations/modbus", + }, + ) + _LOGGER.warning( + "`retry_on_empty`: is deprecated and will be removed in version 2024.4" ) # generic configuration self._client: ModbusBaseClient | None = None @@ -298,7 +316,7 @@ def __init__(self, hass: HomeAssistant, client_config: dict[str, Any]) -> None: "port": client_config[CONF_PORT], "timeout": client_config[CONF_TIMEOUT], "retries": client_config[CONF_RETRIES], - "retry_on_empty": client_config[CONF_RETRY_ON_EMPTY], + "retry_on_empty": True, } if self._config_type == SERIAL: # serial configuration diff --git a/homeassistant/components/modbus/strings.json b/homeassistant/components/modbus/strings.json index 780757a3eeb8b3..5f45d0df5963ba 100644 --- a/homeassistant/components/modbus/strings.json +++ b/homeassistant/components/modbus/strings.json @@ -73,6 +73,10 @@ "deprecated_close_comm_config": { "title": "`{config_key}` configuration key is being removed", "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nCommunication is automatically closed on errors, see [the documentation]({url}) for other error handling parameters." + }, + "deprecated_retry_on_empty": { + "title": "`{config_key}` configuration key is being removed", + "description": "Please remove the `{config_key}` key from the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue.\n\nRetry on empty is automatically applied, see [the documentation]({url}) for other error handling parameters." } } } diff --git a/tests/components/modbus/test_init.py b/tests/components/modbus/test_init.py index 5f8c0554e6d851..e66115f24d9c6b 100644 --- a/tests/components/modbus/test_init.py +++ b/tests/components/modbus/test_init.py @@ -46,6 +46,7 @@ CONF_INPUT_TYPE, CONF_MSG_WAIT, CONF_PARITY, + CONF_RETRY_ON_EMPTY, CONF_SLAVE_COUNT, CONF_STOPBITS, CONF_SWAP, @@ -420,6 +421,12 @@ async def test_duplicate_entity_validator(do_config) -> None: CONF_PORT: TEST_PORT_TCP, CONF_CLOSE_COMM_ON_ERROR: True, }, + { + CONF_TYPE: TCP, + CONF_HOST: TEST_MODBUS_HOST, + CONF_PORT: TEST_PORT_TCP, + CONF_RETRY_ON_EMPTY: True, + }, { CONF_TYPE: TCP, CONF_HOST: TEST_MODBUS_HOST, From ddeb2854aa3e1f1fc88df738976c7a6f43d0d724 Mon Sep 17 00:00:00 2001 From: Dennis Date: Sun, 17 Sep 2023 00:40:16 +0200 Subject: [PATCH 568/640] Added device class to speedtestdotnet sensor entities. (#100500) Added device class to sensor entities. --- homeassistant/components/speedtestdotnet/sensor.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/speedtestdotnet/sensor.py b/homeassistant/components/speedtestdotnet/sensor.py index ccd2008503c357..af41c400e0b671 100644 --- a/homeassistant/components/speedtestdotnet/sensor.py +++ b/homeassistant/components/speedtestdotnet/sensor.py @@ -6,6 +6,7 @@ from typing import Any, cast from homeassistant.components.sensor import ( + SensorDeviceClass, SensorEntity, SensorEntityDescription, SensorStateClass, @@ -45,12 +46,14 @@ class SpeedtestSensorEntityDescription(SensorEntityDescription): translation_key="ping", native_unit_of_measurement=UnitOfTime.MILLISECONDS, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DURATION, ), SpeedtestSensorEntityDescription( key="download", translation_key="download", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), SpeedtestSensorEntityDescription( @@ -58,6 +61,7 @@ class SpeedtestSensorEntityDescription(SensorEntityDescription): translation_key="upload", native_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND, state_class=SensorStateClass.MEASUREMENT, + device_class=SensorDeviceClass.DATA_RATE, value=lambda value: round(value / 10**6, 2), ), ) From 48f9a38c7487f49447ec0740edc33d7b2e8f146b Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Sun, 17 Sep 2023 16:49:21 +0200 Subject: [PATCH 569/640] Update numpy to 1.26.0 (#100512) --- 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 7b59879025ee2f..e166ca716cb76f 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.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/components/iqvia/manifest.json b/homeassistant/components/iqvia/manifest.json index 315d063d6aa7fc..ce519de1b67039 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.23.2", "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 da541974b46e92..3c484385934267 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.23.2", "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 47a4ddd0653be6..45e9a96d7590ef 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.23.2"] + "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 71952431b5ae3b..bfd3e77ee50cf9 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.23.2", + "numpy==1.26.0", "Pillow==10.0.0" ] } diff --git a/homeassistant/components/trend/manifest.json b/homeassistant/components/trend/manifest.json index 9bb5c4296c5f14..0adbf62334640d 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.23.2"] + "requirements": ["numpy==1.26.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 98846c0a9682f7..5aa3a010d642ce 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -112,7 +112,7 @@ httpcore==0.17.3 hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +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 050cd284489896..b761d2bcaa10d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1315,7 +1315,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +numpy==1.26.0 # homeassistant.components.oasa_telematics oasatelematics==0.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 91ac52958476b5..010728ced0664b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1014,7 +1014,7 @@ numato-gpio==0.10.0 # homeassistant.components.stream # homeassistant.components.tensorflow # homeassistant.components.trend -numpy==1.23.2 +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 8780b9d07438ed..e0e00ebc958618 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -113,7 +113,7 @@ hyperframe>=5.2.0 # Ensure we run compatible with musllinux build env -numpy==1.23.2 +numpy==1.26.0 # Prevent dependency conflicts between sisyphus-control and aioambient # until upper bounds for sisyphus-control have been updated From 7aa02b86214214705142106cbd7b96b8bec6a6db Mon Sep 17 00:00:00 2001 From: tronikos Date: Sun, 17 Sep 2023 07:50:17 -0700 Subject: [PATCH 570/640] Bump opower to 0.0.34 (#100501) --- 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 05e89ea96d4bdc..002495b951791c 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.33"] + "requirements": ["opower==0.0.34"] } diff --git a/requirements_all.txt b/requirements_all.txt index b761d2bcaa10d6..4e14af046379bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1378,7 +1378,7 @@ openwrt-luci-rpc==1.1.16 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 010728ced0664b..25d825691e4701 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1050,7 +1050,7 @@ openerz-api==0.2.0 openhomedevice==2.2.0 # homeassistant.components.opower -opower==0.0.33 +opower==0.0.34 # homeassistant.components.oralb oralb-ble==0.17.6 From 2794ab1782a6ab43ab15719cfdc8713c241cc894 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Sun, 17 Sep 2023 19:11:44 +0300 Subject: [PATCH 571/640] Fix huawei_lte current month up/download sensor error on delete (#100506) Deleting one of them prematurely deleted the last reset item subscription that is shared between the two. --- homeassistant/components/huawei_lte/__init__.py | 6 ++++-- homeassistant/components/huawei_lte/binary_sensor.py | 4 +++- homeassistant/components/huawei_lte/device_tracker.py | 4 ++-- homeassistant/components/huawei_lte/sensor.py | 4 ++-- homeassistant/components/huawei_lte/switch.py | 2 +- 5 files changed, 12 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index f21f084a544f14..929ca0193af670 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -143,9 +143,11 @@ class Router: url: str data: dict[str, Any] = field(default_factory=dict, init=False) - subscriptions: dict[str, set[str]] = field( + # Values are lists rather than sets, because the same item may be used by more than + # one thing, such as MonthDuration for CurrentMonth{Download,Upload}. + subscriptions: dict[str, list[str]] = field( default_factory=lambda: defaultdict( - set, ((x, {"initial_scan"}) for x in ALL_KEYS) + list, ((x, ["initial_scan"]) for x in ALL_KEYS) ), init=False, ) diff --git a/homeassistant/components/huawei_lte/binary_sensor.py b/homeassistant/components/huawei_lte/binary_sensor.py index a1a26b516573d2..2d96a4e04260fd 100644 --- a/homeassistant/components/huawei_lte/binary_sensor.py +++ b/homeassistant/components/huawei_lte/binary_sensor.py @@ -65,7 +65,9 @@ def _device_unique_id(self) -> str: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{BINARY_SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append( + f"{BINARY_SENSOR_DOMAIN}/{self.item}" + ) async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" diff --git a/homeassistant/components/huawei_lte/device_tracker.py b/homeassistant/components/huawei_lte/device_tracker.py index b8833b24d921e4..665c96e4888410 100644 --- a/homeassistant/components/huawei_lte/device_tracker.py +++ b/homeassistant/components/huawei_lte/device_tracker.py @@ -90,8 +90,8 @@ async def async_setup_entry( async_add_entities(known_entities, True) # Tell parent router to poll hosts list to gather new devices - router.subscriptions[KEY_LAN_HOST_INFO].add(_DEVICE_SCAN) - router.subscriptions[KEY_WLAN_HOST_LIST].add(_DEVICE_SCAN) + router.subscriptions[KEY_LAN_HOST_INFO].append(_DEVICE_SCAN) + router.subscriptions[KEY_WLAN_HOST_LIST].append(_DEVICE_SCAN) async def _async_maybe_add_new_entities(unique_id: str) -> None: """Add new entities if the update signal comes from our router.""" diff --git a/homeassistant/components/huawei_lte/sensor.py b/homeassistant/components/huawei_lte/sensor.py index 133b569c75159e..a4321bfd93f70d 100644 --- a/homeassistant/components/huawei_lte/sensor.py +++ b/homeassistant/components/huawei_lte/sensor.py @@ -724,9 +724,9 @@ def __post_init__(self) -> None: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{SENSOR_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SENSOR_DOMAIN}/{self.item}") if self.entity_description.last_reset_item: - self.router.subscriptions[self.key].add( + self.router.subscriptions[self.key].append( f"{SENSOR_DOMAIN}/{self.entity_description.last_reset_item}" ) diff --git a/homeassistant/components/huawei_lte/switch.py b/homeassistant/components/huawei_lte/switch.py index 2fe064d630092c..f75cf14e89b42d 100644 --- a/homeassistant/components/huawei_lte/switch.py +++ b/homeassistant/components/huawei_lte/switch.py @@ -69,7 +69,7 @@ def turn_off(self, **kwargs: Any) -> None: async def async_added_to_hass(self) -> None: """Subscribe to needed data on add.""" await super().async_added_to_hass() - self.router.subscriptions[self.key].add(f"{SWITCH_DOMAIN}/{self.item}") + self.router.subscriptions[self.key].append(f"{SWITCH_DOMAIN}/{self.item}") async def async_will_remove_from_hass(self) -> None: """Unsubscribe from needed data on remove.""" From dd1dc52994affc953e02ae4ce7e23e7ce197ce29 Mon Sep 17 00:00:00 2001 From: Markus Friedli Date: Sun, 17 Sep 2023 20:00:09 +0200 Subject: [PATCH 572/640] Fix broken reconnect capability of fritzbox_callmonitor (#100526) --- homeassistant/components/fritz/manifest.json | 2 +- homeassistant/components/fritzbox_callmonitor/manifest.json | 2 +- homeassistant/components/fritzbox_callmonitor/sensor.py | 6 +++++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 5 files changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/fritz/manifest.json b/homeassistant/components/fritz/manifest.json index 8d52115d49b3dd..d8d8f6b94bfce3 100644 --- a/homeassistant/components/fritz/manifest.json +++ b/homeassistant/components/fritz/manifest.json @@ -7,7 +7,7 @@ "documentation": "https://www.home-assistant.io/integrations/fritz", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2", "xmltodict==0.13.0"], + "requirements": ["fritzconnection[qr]==1.13.2", "xmltodict==0.13.0"], "ssdp": [ { "st": "urn:schemas-upnp-org:device:fritzbox:1" diff --git a/homeassistant/components/fritzbox_callmonitor/manifest.json b/homeassistant/components/fritzbox_callmonitor/manifest.json index c3c305ab07ece8..4e5c60091c9025 100644 --- a/homeassistant/components/fritzbox_callmonitor/manifest.json +++ b/homeassistant/components/fritzbox_callmonitor/manifest.json @@ -7,5 +7,5 @@ "integration_type": "device", "iot_class": "local_polling", "loggers": ["fritzconnection"], - "requirements": ["fritzconnection[qr]==1.12.2"] + "requirements": ["fritzconnection[qr]==1.13.2"] } diff --git a/homeassistant/components/fritzbox_callmonitor/sensor.py b/homeassistant/components/fritzbox_callmonitor/sensor.py index 11c3166fd88297..cc239895c38238 100644 --- a/homeassistant/components/fritzbox_callmonitor/sensor.py +++ b/homeassistant/components/fritzbox_callmonitor/sensor.py @@ -192,7 +192,11 @@ def connect(self) -> None: _LOGGER.debug("Setting up socket connection") try: self.connection = FritzMonitor(address=self.host, port=self.port) - kwargs: dict[str, Any] = {"event_queue": self.connection.start()} + kwargs: dict[str, Any] = { + "event_queue": self.connection.start( + reconnect_tries=50, reconnect_delay=120 + ) + } Thread(target=self._process_events, kwargs=kwargs).start() except OSError as err: self.connection = None diff --git a/requirements_all.txt b/requirements_all.txt index 4e14af046379bb..2329462c46b2de 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -834,7 +834,7 @@ freesms==0.2.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 25d825691e4701..c17717ce7af89e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -656,7 +656,7 @@ freebox-api==1.1.0 # homeassistant.components.fritz # homeassistant.components.fritzbox_callmonitor -fritzconnection[qr]==1.12.2 +fritzconnection[qr]==1.13.2 # homeassistant.components.google_translate gTTS==2.2.4 From 868afc037efe3bd6de3628bc415f64feb22a6d71 Mon Sep 17 00:00:00 2001 From: starkillerOG Date: Sun, 17 Sep 2023 22:28:52 +0200 Subject: [PATCH 573/640] Try Reolink ONVIF long polling if ONVIF push not supported (#100375) --- homeassistant/components/reolink/host.py | 56 ++++++++++++++++++------ 1 file changed, 42 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/reolink/host.py b/homeassistant/components/reolink/host.py index a43dbce9a7c523..2487013b032726 100644 --- a/homeassistant/components/reolink/host.py +++ b/homeassistant/components/reolink/host.py @@ -61,7 +61,8 @@ def __init__( ) self.webhook_id: str | None = None - self._onvif_supported: bool = True + self._onvif_push_supported: bool = True + self._onvif_long_poll_supported: bool = True self._base_url: str = "" self._webhook_url: str = "" self._webhook_reachable: bool = False @@ -97,7 +98,9 @@ async def async_init(self) -> None: f"'{self._api.user_level}', only admin users can change camera settings" ) - self._onvif_supported = self._api.supported(None, "ONVIF") + onvif_supported = self._api.supported(None, "ONVIF") + self._onvif_push_supported = onvif_supported + self._onvif_long_poll_supported = onvif_supported enable_rtsp = None enable_onvif = None @@ -109,7 +112,7 @@ async def async_init(self) -> None: ) enable_rtsp = True - if not self._api.onvif_enabled and self._onvif_supported: + if not self._api.onvif_enabled and onvif_supported: _LOGGER.debug( "ONVIF is disabled on %s, trying to enable it", self._api.nvr_name ) @@ -157,11 +160,11 @@ async def async_init(self) -> None: self._unique_id = format_mac(self._api.mac_address) - if self._onvif_supported: + if self._onvif_push_supported: try: await self.subscribe() except NotSupportedError: - self._onvif_supported = False + self._onvif_push_supported = False self.unregister_webhook() await self._api.unsubscribe() else: @@ -179,12 +182,27 @@ async def async_init(self) -> None: self._cancel_onvif_check = async_call_later( self._hass, FIRST_ONVIF_TIMEOUT, self._async_check_onvif ) - if not self._onvif_supported: + if not self._onvif_push_supported: _LOGGER.debug( - "Camera model %s does not support ONVIF, using fast polling instead", + "Camera model %s does not support ONVIF push, using ONVIF long polling instead", self._api.model, ) - await self._async_poll_all_motion() + try: + await self._async_start_long_polling(initial=True) + except NotSupportedError: + _LOGGER.debug( + "Camera model %s does not support ONVIF long polling, using fast polling instead", + self._api.model, + ) + self._onvif_long_poll_supported = False + await self._api.unsubscribe() + await self._async_poll_all_motion() + else: + self._cancel_long_poll_check = async_call_later( + self._hass, + FIRST_ONVIF_LONG_POLL_TIMEOUT, + self._async_check_onvif_long_poll, + ) if self._api.sw_version_update_required: ir.async_create_issue( @@ -317,11 +335,22 @@ async def disconnect(self): str(err), ) - async def _async_start_long_polling(self): + async def _async_start_long_polling(self, initial=False): """Start ONVIF long polling task.""" if self._long_poll_task is None: try: await self._api.subscribe(sub_type=SubType.long_poll) + except NotSupportedError as err: + if initial: + raise err + # make sure the long_poll_task is always created to try again later + if not self._lost_subscription: + self._lost_subscription = True + _LOGGER.error( + "Reolink %s event long polling subscription lost: %s", + self._api.nvr_name, + str(err), + ) except ReolinkError as err: # make sure the long_poll_task is always created to try again later if not self._lost_subscription: @@ -381,12 +410,11 @@ async def subscribe(self) -> None: async def renew(self) -> None: """Renew the subscription of motion events (lease time is 15 minutes).""" - if not self._onvif_supported: - return - try: - await self._renew(SubType.push) - if self._long_poll_task is not None: + if self._onvif_push_supported: + await self._renew(SubType.push) + + if self._onvif_long_poll_supported and self._long_poll_task is not None: if not self._api.subscribed(SubType.long_poll): _LOGGER.debug("restarting long polling task") # To prevent 5 minute request timeout From 6acb182c38788a768d892ca154e484b917c13e1f Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 00:05:29 +0200 Subject: [PATCH 574/640] Fix full black run condition [ci] (#100532) --- .github/workflows/ci.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c20886f23425fa..2ac6773b6e906b 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -311,6 +311,7 @@ jobs: env.BLACK_CACHE_VERSION }}-${{ steps.generate-black-key.outputs.version }}-${{ env.HA_SHORT_VERSION }}- - name: Run black (fully) + if: needs.info.outputs.test_full_suite == 'true' env: BLACK_CACHE_DIR: ${{ env.BLACK_CACHE }} run: | From f6243a1f79395c2ae4e642e81302399d712b7f66 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Sun, 17 Sep 2023 22:38:08 +0000 Subject: [PATCH 575/640] Add `event` platform for Shelly gen2 devices (#99659) * Add event platform for gen2 devices * Add tests * Add removal condition * Simplify RpcEventDescription; fix availability * Improve names and docstrings * Improve the event entity name * Use async_on_remove() * Improve tests coverage * Improve tests coverage * Prefix the entity name with the device name in the old way * Black * Use DeviceInfo object --- homeassistant/components/shelly/__init__.py | 1 + .../components/shelly/coordinator.py | 16 +++ homeassistant/components/shelly/event.py | 107 ++++++++++++++++++ homeassistant/components/shelly/utils.py | 10 ++ tests/components/shelly/conftest.py | 1 + tests/components/shelly/test_event.py | 70 ++++++++++++ tests/components/shelly/test_utils.py | 13 +++ 7 files changed, 218 insertions(+) create mode 100644 homeassistant/components/shelly/event.py create mode 100644 tests/components/shelly/test_event.py diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index 09d9e3655f080a..29a0506fcc0f88 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -73,6 +73,7 @@ Platform.BINARY_SENSOR, Platform.BUTTON, Platform.COVER, + Platform.EVENT, Platform.LIGHT, Platform.SENSOR, Platform.SWITCH, diff --git a/homeassistant/components/shelly/coordinator.py b/homeassistant/components/shelly/coordinator.py index d0530efa149d11..c19aac93dabca9 100644 --- a/homeassistant/components/shelly/coordinator.py +++ b/homeassistant/components/shelly/coordinator.py @@ -389,6 +389,7 @@ def __init__( self._connection_lock = asyncio.Lock() self._event_listeners: list[Callable[[dict[str, Any]], None]] = [] self._ota_event_listeners: list[Callable[[dict[str, Any]], None]] = [] + self._input_event_listeners: list[Callable[[dict[str, Any]], None]] = [] entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._handle_ha_stop) @@ -426,6 +427,19 @@ def _unsubscribe() -> None: return _unsubscribe + @callback + def async_subscribe_input_events( + self, input_event_callback: Callable[[dict[str, Any]], None] + ) -> CALLBACK_TYPE: + """Subscribe to input events.""" + + def _unsubscribe() -> None: + self._input_event_listeners.remove(input_event_callback) + + self._input_event_listeners.append(input_event_callback) + + return _unsubscribe + @callback def async_subscribe_events( self, event_callback: Callable[[dict[str, Any]], None] @@ -469,6 +483,8 @@ def _async_device_event_handler(self, event_data: dict[str, Any]) -> None: ) self.hass.async_create_task(self._debounced_reload.async_call()) elif event_type in RPC_INPUTS_EVENTS_TYPES: + for event_callback in self._input_event_listeners: + event_callback(event) self.hass.bus.async_fire( EVENT_SHELLY_CLICK, { diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py new file mode 100644 index 00000000000000..e37b4cdcdacb0c --- /dev/null +++ b/homeassistant/components/shelly/event.py @@ -0,0 +1,107 @@ +"""Event for Shelly.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any, Final + +from homeassistant.components.event import ( + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, + EventEntity, + EventEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import RPC_INPUTS_EVENTS_TYPES +from .coordinator import ShellyRpcCoordinator, get_entry_data +from .utils import ( + async_remove_shelly_entity, + get_device_entry_gen, + get_rpc_input_name, + get_rpc_key_instances, + is_rpc_momentary_input, +) + + +@dataclass +class ShellyEventDescription(EventEntityDescription): + """Class to describe Shelly event.""" + + removal_condition: Callable[[dict, dict, str], bool] | None = None + + +RPC_EVENT: Final = ShellyEventDescription( + key="input", + device_class=EventDeviceClass.BUTTON, + event_types=list(RPC_INPUTS_EVENTS_TYPES), + removal_condition=lambda config, status, key: not is_rpc_momentary_input( + config, status, key + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up sensors for device.""" + if get_device_entry_gen(config_entry) == 2: + coordinator = get_entry_data(hass)[config_entry.entry_id].rpc + assert coordinator + + entities = [] + key_instances = get_rpc_key_instances(coordinator.device.status, RPC_EVENT.key) + + for key in key_instances: + if RPC_EVENT.removal_condition and RPC_EVENT.removal_condition( + coordinator.device.config, coordinator.device.status, key + ): + unique_id = f"{coordinator.mac}-{key}" + async_remove_shelly_entity(hass, EVENT_DOMAIN, unique_id) + else: + entities.append(ShellyRpcEvent(coordinator, key, RPC_EVENT)) + + async_add_entities(entities) + + +class ShellyRpcEvent(CoordinatorEntity[ShellyRpcCoordinator], EventEntity): + """Represent RPC event entity.""" + + _attr_should_poll = False + entity_description: ShellyEventDescription + + def __init__( + self, + coordinator: ShellyRpcCoordinator, + key: str, + description: ShellyEventDescription, + ) -> None: + """Initialize Shelly entity.""" + super().__init__(coordinator) + self.input_index = int(key.split(":")[-1]) + self._attr_device_info = DeviceInfo( + connections={(CONNECTION_NETWORK_MAC, coordinator.mac)} + ) + self._attr_unique_id = f"{coordinator.mac}-{key}" + self._attr_name = get_rpc_input_name(coordinator.device, key) + self.entity_description = description + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self.async_on_remove( + self.coordinator.async_subscribe_input_events(self._async_handle_event) + ) + + @callback + def _async_handle_event(self, event: dict[str, Any]) -> None: + """Handle the demo button event.""" + if event["id"] == self.input_index: + self._trigger_event(event["event"]) + self.async_write_ha_state() diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index e78b44db15eb25..5633f674168717 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -285,6 +285,16 @@ def get_model_name(info: dict[str, Any]) -> str: return cast(str, MODEL_NAMES.get(info["type"], info["type"])) +def get_rpc_input_name(device: RpcDevice, key: str) -> str: + """Get input name based from the device configuration.""" + input_config = device.config[key] + + if input_name := input_config.get("name"): + return f"{device.name} {input_name}" + + return f"{device.name} {key.replace(':', ' ').capitalize()}" + + def get_rpc_channel_name(device: RpcDevice, key: str) -> str: """Get name based on device and channel name.""" key = key.replace("emdata", "em") diff --git a/tests/components/shelly/conftest.py b/tests/components/shelly/conftest.py index e72604260f5cb3..00f88561880407 100644 --- a/tests/components/shelly/conftest.py +++ b/tests/components/shelly/conftest.py @@ -191,6 +191,7 @@ def mock_light_set_state( MOCK_STATUS_RPC = { "switch:0": {"output": True}, + "input:0": {"id": 0, "state": None}, "light:0": {"output": True, "brightness": 53.0}, "cloud": {"connected": False}, "cover:0": { diff --git a/tests/components/shelly/test_event.py b/tests/components/shelly/test_event.py new file mode 100644 index 00000000000000..8222e42408bad1 --- /dev/null +++ b/tests/components/shelly/test_event.py @@ -0,0 +1,70 @@ +"""Tests for Shelly button platform.""" +from __future__ import annotations + +from pytest_unordered import unordered + +from homeassistant.components.event import ( + ATTR_EVENT_TYPE, + ATTR_EVENT_TYPES, + DOMAIN as EVENT_DOMAIN, + EventDeviceClass, +) +from homeassistant.const import ATTR_DEVICE_CLASS, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_registry import async_get + +from . import init_integration, inject_rpc_device_event, register_entity + + +async def test_rpc_button(hass: HomeAssistant, mock_rpc_device, monkeypatch) -> None: + """Test RPC device event.""" + await init_integration(hass, 2) + entity_id = "event.test_name_input_0" + registry = async_get(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + assert state.attributes.get(ATTR_EVENT_TYPES) == unordered( + ["btn_down", "btn_up", "double_push", "long_push", "single_push", "triple_push"] + ) + assert state.attributes.get(ATTR_EVENT_TYPE) is None + assert state.attributes.get(ATTR_DEVICE_CLASS) == EventDeviceClass.BUTTON + + entry = registry.async_get(entity_id) + assert entry + assert entry.unique_id == "123456789ABC-input:0" + + inject_rpc_device_event( + monkeypatch, + mock_rpc_device, + { + "events": [ + { + "event": "single_push", + "id": 0, + "ts": 1668522399.2, + } + ], + "ts": 1668522399.2, + }, + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id) + assert state.attributes.get(ATTR_EVENT_TYPE) == "single_push" + + +async def test_rpc_event_removal( + hass: HomeAssistant, mock_rpc_device, monkeypatch +) -> None: + """Test RPC event entity is removed due to removal_condition.""" + registry = async_get(hass) + entity_id = register_entity(hass, EVENT_DOMAIN, "test_name_input_0", "input:0") + + assert registry.async_get(entity_id) is not None + + monkeypatch.setitem(mock_rpc_device.config, "input:0", {"id": 0, "type": "switch"}) + await init_integration(hass, 2) + + assert registry.async_get(entity_id) is None diff --git a/tests/components/shelly/test_utils.py b/tests/components/shelly/test_utils.py index 1bf660deb2a4c5..a163519c9d11ab 100644 --- a/tests/components/shelly/test_utils.py +++ b/tests/components/shelly/test_utils.py @@ -8,6 +8,7 @@ get_device_uptime, get_number_of_channels, get_rpc_channel_name, + get_rpc_input_name, get_rpc_input_triggers, is_block_momentary_input, ) @@ -210,6 +211,18 @@ async def test_get_rpc_channel_name(mock_rpc_device) -> None: assert get_rpc_channel_name(mock_rpc_device, "input:3") == "Test name switch_3" +async def test_get_rpc_input_name(mock_rpc_device, monkeypatch) -> None: + """Test get RPC input name.""" + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input 0" + + monkeypatch.setitem( + mock_rpc_device.config, + "input:0", + {"id": 0, "type": "button", "name": "Input name"}, + ) + assert get_rpc_input_name(mock_rpc_device, "input:0") == "Test name Input name" + + async def test_get_rpc_input_triggers(mock_rpc_device, monkeypatch) -> None: """Test get RPC input triggers.""" monkeypatch.setattr(mock_rpc_device, "config", {"input:0": {"type": "button"}}) From 276d245409dd03235399536fd5cee352e2331fcf Mon Sep 17 00:00:00 2001 From: Glenn Waters Date: Sun, 17 Sep 2023 21:39:23 -0400 Subject: [PATCH 576/640] Bump elkm1-lib to 2.2.6 (#100537) --- homeassistant/components/elkm1/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/elkm1/manifest.json b/homeassistant/components/elkm1/manifest.json index ccac1593fa002c..3ec5be46d41cfd 100644 --- a/homeassistant/components/elkm1/manifest.json +++ b/homeassistant/components/elkm1/manifest.json @@ -15,5 +15,5 @@ "documentation": "https://www.home-assistant.io/integrations/elkm1", "iot_class": "local_push", "loggers": ["elkm1_lib"], - "requirements": ["elkm1-lib==2.2.5"] + "requirements": ["elkm1-lib==2.2.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2329462c46b2de..2a9f39baf4cf98 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -729,7 +729,7 @@ elgato==4.0.1 eliqonline==1.2.2 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c17717ce7af89e..2bac718dc57911 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -588,7 +588,7 @@ electrickiwi-api==0.8.5 elgato==4.0.1 # homeassistant.components.elkm1 -elkm1-lib==2.2.5 +elkm1-lib==2.2.6 # homeassistant.components.elmax elmax-api==0.0.4 From f41e3a2beb315f57a5c96201a86e5f4dea3f035c Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 10:52:43 +0200 Subject: [PATCH 577/640] Remove duplicate mobile_app client fixture (#100530) --- tests/components/mobile_app/conftest.py | 24 ++++++--------------- tests/components/mobile_app/test_webhook.py | 4 ++-- 2 files changed, 8 insertions(+), 20 deletions(-) diff --git a/tests/components/mobile_app/conftest.py b/tests/components/mobile_app/conftest.py index e7c9ad4995a4f8..f69912f176c713 100644 --- a/tests/components/mobile_app/conftest.py +++ b/tests/components/mobile_app/conftest.py @@ -10,18 +10,16 @@ @pytest.fixture -async def create_registrations(hass, authed_api_client): +async def create_registrations(hass, webhook_client): """Return two new registrations.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( - "/api/mobile_app/registrations", json=REGISTER - ) + enc_reg = await webhook_client.post("/api/mobile_app/registrations", json=REGISTER) assert enc_reg.status == HTTPStatus.CREATED enc_reg_json = await enc_reg.json() - clear_reg = await authed_api_client.post( + clear_reg = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) @@ -34,11 +32,11 @@ async def create_registrations(hass, authed_api_client): @pytest.fixture -async def push_registration(hass, authed_api_client): +async def push_registration(hass, webhook_client): """Return registration with push notifications enabled.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - enc_reg = await authed_api_client.post( + enc_reg = await webhook_client.post( "/api/mobile_app/registrations", json={ **REGISTER, @@ -54,17 +52,7 @@ async def push_registration(hass, authed_api_client): @pytest.fixture -async def webhook_client(hass, authed_api_client, aiohttp_client): - """mobile_app mock client.""" - # We pass in the authed_api_client server instance because - # it is used inside create_registrations and just passing in - # the app instance would cause the server to start twice, - # which caused deprecation warnings to be printed. - return await aiohttp_client(authed_api_client.server) - - -@pytest.fixture -async def authed_api_client(hass, hass_client): +async def webhook_client(hass, hass_client): """Provide an authenticated client for mobile_app to use.""" await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) await hass.async_block_till_done() diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 4faf48e2118a62..9f6aec404e2b37 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -196,9 +196,9 @@ def store_event(event): assert events[0].data["hello"] == "yo world" -async def test_webhook_update_registration(webhook_client, authed_api_client) -> None: +async def test_webhook_update_registration(webhook_client) -> None: """Test that a we can update an existing registration via webhook.""" - register_resp = await authed_api_client.post( + register_resp = await webhook_client.post( "/api/mobile_app/registrations", json=REGISTER_CLEARTEXT ) From 902f997ee020539e0c0d76646e07cc15a76ae362 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Mon, 18 Sep 2023 12:39:43 +0300 Subject: [PATCH 578/640] Fix google invalid token expiry test init for UTC offsets > 0 (#100533) ``` $ python3 -q >>> import datetime, time >>> time.tzname ('EET', 'EEST') >>> datetime.datetime.max.timestamp() Traceback (most recent call last): File "", line 1, in ValueError: year 10000 is out of range ``` --- tests/components/google/test_init.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/components/google/test_init.py b/tests/components/google/test_init.py index 17f300f58cb51b..233635510e07ae 100644 --- a/tests/components/google/test_init.py +++ b/tests/components/google/test_init.py @@ -20,7 +20,7 @@ from homeassistant.const import ATTR_FRIENDLY_NAME, STATE_OFF from homeassistant.core import HomeAssistant, State from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.dt import utcnow +from homeassistant.util.dt import UTC, utcnow from .conftest import ( CALENDAR_ID, @@ -645,7 +645,8 @@ async def test_add_event_location( @pytest.mark.parametrize( - "config_entry_token_expiry", [datetime.datetime.max.timestamp() + 1] + "config_entry_token_expiry", + [datetime.datetime.max.replace(tzinfo=UTC).timestamp() + 1], ) async def test_invalid_token_expiry_in_config_entry( hass: HomeAssistant, From 45c0dc68544a7bee4dd5e6f8b2f4f0c5606757f1 Mon Sep 17 00:00:00 2001 From: steffenrapp <88974099+steffenrapp@users.noreply.github.com> Date: Mon, 18 Sep 2023 11:44:41 +0200 Subject: [PATCH 579/640] Add missing conversation service translation (#100308) * Update services.yaml * Update strings.json * Update services.yaml * Update strings.json * Update strings.json * fix translation keys * Fix translation keys --- .../components/conversation/services.yaml | 11 +++++++++++ homeassistant/components/conversation/strings.json | 14 ++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml index 7b6717eec6d3e9..953db065614416 100644 --- a/homeassistant/components/conversation/services.yaml +++ b/homeassistant/components/conversation/services.yaml @@ -14,3 +14,14 @@ process: example: homeassistant selector: conversation_agent: + +reload: + fields: + language: + example: NL + selector: + text: + agent_id: + example: homeassistant + selector: + conversation_agent: diff --git a/homeassistant/components/conversation/strings.json b/homeassistant/components/conversation/strings.json index 15e783c0d90afb..8240cfa3f82cdc 100644 --- a/homeassistant/components/conversation/strings.json +++ b/homeassistant/components/conversation/strings.json @@ -18,6 +18,20 @@ "description": "Conversation agent to process your request. The conversation agent is the brains of your assistant. It processes the incoming text commands." } } + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads the intent configuration.", + "fields": { + "language": { + "name": "[%key:component::conversation::services::process::fields::language::name%]", + "description": "Language to clear cached intents for. Defaults to server language." + }, + "agent_id": { + "name": "[%key:component::conversation::services::process::fields::agent_id::name%]", + "description": "Conversation agent to reload." + } + } } } } From dc2afb71ae669f537fa9d3a1c85d256f0f561500 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 18 Sep 2023 11:56:28 +0200 Subject: [PATCH 580/640] Move co2signal coordinator to its own file (#100541) * Move co2signal coordinator to its own file * Fix import --- .../components/co2signal/__init__.py | 85 +----------------- .../components/co2signal/config_flow.py | 2 +- .../components/co2signal/coordinator.py | 90 +++++++++++++++++++ .../components/co2signal/diagnostics.py | 3 +- homeassistant/components/co2signal/sensor.py | 2 +- 5 files changed, 97 insertions(+), 85 deletions(-) create mode 100644 homeassistant/components/co2signal/coordinator.py diff --git a/homeassistant/components/co2signal/__init__.py b/homeassistant/components/co2signal/__init__.py index 79c56ec63d4db9..04ae811197bfb3 100644 --- a/homeassistant/components/co2signal/__init__.py +++ b/homeassistant/components/co2signal/__init__.py @@ -1,25 +1,14 @@ """The CO2 Signal integration.""" from __future__ import annotations -from collections.abc import Mapping -from datetime import timedelta -import logging -from typing import Any, cast - -import CO2Signal - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed -from .const import CONF_COUNTRY_CODE, DOMAIN -from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError -from .models import CO2SignalResponse +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator PLATFORMS = [Platform.SENSOR] -_LOGGER = logging.getLogger(__name__) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -35,71 +24,3 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): - """Data update coordinator.""" - - def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: - """Initialize the coordinator.""" - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) - ) - self._entry = entry - - @property - def entry_id(self) -> str: - """Return entry ID.""" - return self._entry.entry_id - - async def _async_update_data(self) -> CO2SignalResponse: - """Fetch the latest data from the source.""" - try: - data = await self.hass.async_add_executor_job( - get_data, self.hass, self._entry.data - ) - except InvalidAuth as err: - raise ConfigEntryAuthFailed from err - except CO2Error as err: - raise UpdateFailed(str(err)) from err - - return data - - -def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: - """Get data from the API.""" - if CONF_COUNTRY_CODE in config: - latitude = None - longitude = None - else: - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - try: - data = CO2Signal.get_latest( - config[CONF_API_KEY], - config.get(CONF_COUNTRY_CODE), - latitude, - longitude, - wait=False, - ) - - except ValueError as err: - err_str = str(err) - - if "Invalid authentication credentials" in err_str: - raise InvalidAuth from err - if "API rate limit exceeded." in err_str: - raise APIRatelimitExceeded from err - - _LOGGER.exception("Unexpected exception") - raise UnknownError from err - - if "error" in data: - raise UnknownError(data["error"]) - - if data.get("status") != "ok": - _LOGGER.exception("Unexpected response: %s", data) - raise UnknownError - - return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/config_flow.py b/homeassistant/components/co2signal/config_flow.py index 2ac3ebc398fae9..92b09b6e17a817 100644 --- a/homeassistant/components/co2signal/config_flow.py +++ b/homeassistant/components/co2signal/config_flow.py @@ -10,8 +10,8 @@ from homeassistant.data_entry_flow import FlowResult import homeassistant.helpers.config_validation as cv -from . import get_data from .const import CONF_COUNTRY_CODE, DOMAIN +from .coordinator import get_data from .exceptions import APIRatelimitExceeded, InvalidAuth from .util import get_extra_name diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py new file mode 100644 index 00000000000000..2538e913a68647 --- /dev/null +++ b/homeassistant/components/co2signal/coordinator.py @@ -0,0 +1,90 @@ +"""DataUpdateCoordinator for the co2signal integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from datetime import timedelta +import logging +from typing import Any, cast + +import CO2Signal + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_COUNTRY_CODE, DOMAIN +from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError +from .models import CO2SignalResponse + +PLATFORMS = [Platform.SENSOR] +_LOGGER = logging.getLogger(__name__) + + +class CO2SignalCoordinator(DataUpdateCoordinator[CO2SignalResponse]): + """Data update coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=15) + ) + self._entry = entry + + @property + def entry_id(self) -> str: + """Return entry ID.""" + return self._entry.entry_id + + async def _async_update_data(self) -> CO2SignalResponse: + """Fetch the latest data from the source.""" + try: + data = await self.hass.async_add_executor_job( + get_data, self.hass, self._entry.data + ) + except InvalidAuth as err: + raise ConfigEntryAuthFailed from err + except CO2Error as err: + raise UpdateFailed(str(err)) from err + + return data + + +def get_data(hass: HomeAssistant, config: Mapping[str, Any]) -> CO2SignalResponse: + """Get data from the API.""" + if CONF_COUNTRY_CODE in config: + latitude = None + longitude = None + else: + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + try: + data = CO2Signal.get_latest( + config[CONF_API_KEY], + config.get(CONF_COUNTRY_CODE), + latitude, + longitude, + wait=False, + ) + + except ValueError as err: + err_str = str(err) + + if "Invalid authentication credentials" in err_str: + raise InvalidAuth from err + if "API rate limit exceeded." in err_str: + raise APIRatelimitExceeded from err + + _LOGGER.exception("Unexpected exception") + raise UnknownError from err + + if "error" in data: + raise UnknownError(data["error"]) + + if data.get("status") != "ok": + _LOGGER.exception("Unexpected response: %s", data) + raise UnknownError + + return cast(CO2SignalResponse, data) diff --git a/homeassistant/components/co2signal/diagnostics.py b/homeassistant/components/co2signal/diagnostics.py index 8ab09b8cb752da..db08aa4eca6aaa 100644 --- a/homeassistant/components/co2signal/diagnostics.py +++ b/homeassistant/components/co2signal/diagnostics.py @@ -8,7 +8,8 @@ from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant -from . import DOMAIN, CO2SignalCoordinator +from .const import DOMAIN +from .coordinator import CO2SignalCoordinator TO_REDACT = {CONF_API_KEY} diff --git a/homeassistant/components/co2signal/sensor.py b/homeassistant/components/co2signal/sensor.py index c5bc7eb4c2048b..d00bdf70d3e83b 100644 --- a/homeassistant/components/co2signal/sensor.py +++ b/homeassistant/components/co2signal/sensor.py @@ -17,8 +17,8 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import CO2SignalCoordinator from .const import ATTRIBUTION, DOMAIN +from .coordinator import CO2SignalCoordinator SCAN_INTERVAL = timedelta(minutes=3) From 08c4e82cf95341580fc707bc04ae6854fd3d7671 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 11:58:47 +0200 Subject: [PATCH 581/640] Update typing-extensions to 4.8.0 (#100545) --- 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 5aa3a010d642ce..df72b224c63117 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -47,7 +47,7 @@ PyYAML==6.0.1 requests==2.31.0 scapy==2.5.0 SQLAlchemy==2.0.15 -typing-extensions>=4.7.0,<5.0 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous-serialize==2.6.0 voluptuous==0.13.1 diff --git a/pyproject.toml b/pyproject.toml index bfc3472651c7db..28c60e98269174 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,7 @@ dependencies = [ "python-slugify==4.0.1", "PyYAML==6.0.1", "requests==2.31.0", - "typing-extensions>=4.7.0,<5.0", + "typing-extensions>=4.8.0,<5.0", "ulid-transform==0.8.1", "voluptuous==0.13.1", "voluptuous-serialize==2.6.0", diff --git a/requirements.txt b/requirements.txt index 2f6024a2e6a5c5..40f7584ca3172e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ pip>=21.3.1 python-slugify==4.0.1 PyYAML==6.0.1 requests==2.31.0 -typing-extensions>=4.7.0,<5.0 +typing-extensions>=4.8.0,<5.0 ulid-transform==0.8.1 voluptuous==0.13.1 voluptuous-serialize==2.6.0 From 306f39b0535e15d50898b0e2815050a271e004b8 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 12:26:16 +0200 Subject: [PATCH 582/640] Update pytest warnings filter (#100546) --- pyproject.toml | 22 ++++++++++------------ 1 file changed, 10 insertions(+), 12 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 28c60e98269174..a50ef040927558 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -451,12 +451,6 @@ 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/gwww/elkm1/pull/71 - v2.2.5 - "ignore:ssl.PROTOCOL_TLS is deprecated:DeprecationWarning:elkm1_lib.util", - # https://github.com/poljar/matrix-nio/pull/438 - v0.21.2 - "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", - # https://github.com/poljar/matrix-nio/pull/439 - v0.21.2 - "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", # 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 @@ -466,8 +460,6 @@ filterwarnings = [ "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/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/grahamwetzler/smart-meter-texas/pull/143 - v0.5.3 - "ignore:ssl.OP_NO_SSL\\*/ssl.OP_NO_TLS\\* options are deprecated:DeprecationWarning:smart_meter_texas", # https://github.com/pytest-dev/pytest-cov/issues/557 - v4.1.0 # Should resolve itself once pytest-xdist 4.0 is released and the option is removed "ignore:The --rsyncdir command line argument and rsyncdirs config variable are deprecated:DeprecationWarning:xdist.plugin", @@ -477,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/gurumitts/pylutron-caseta/pull/143 - >0.18.1 - "ignore:ssl.PROTOCOL_TLSv1_2 is deprecated:DeprecationWarning:pylutron_caseta.smartbridge", - # https://github.com/Danielhiversen/pyMillLocal/pull/8 - >=0.3.0 - "ignore:with timeout\\(\\) is deprecated:DeprecationWarning:mill_local", + # https://github.com/poljar/matrix-nio/pull/438 - >0.21.2 + "ignore:FormatChecker.cls_checks is deprecated:DeprecationWarning:nio.schemas", + # https://github.com/poljar/matrix-nio/pull/439 - >0.21.2 + "ignore:'cgi' is deprecated and slated for removal in Python 3.13:DeprecationWarning:nio.client.http_client", + # 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", # -- not helpful # pyatmo.__init__ imports deprecated moduls from itself - v7.5.0 @@ -490,6 +484,9 @@ 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", + # 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", # -- unmaintained projects, last release about 2+ years # https://pypi.org/project/agent-py/ - v0.0.23 - 2020-06-04 @@ -505,6 +502,7 @@ filterwarnings = [ # 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 + # https://github.com/vaidik/commentjson/pull/52 # Fixed upstream, commentjson depends on old version and seems to be unmaintained "ignore:module '(sre_parse|sre_constants)' is deprecate:DeprecationWarning:lark.utils", # https://pypi.org/project/lomond/ - v0.3.3 - 2018-09-21 From 6ac1305c6475ac8fed5585951784673edc40ba2e Mon Sep 17 00:00:00 2001 From: jan iversen Date: Mon, 18 Sep 2023 12:39:09 +0200 Subject: [PATCH 583/640] Adjust codeowners in modbus (#100474) --- CODEOWNERS | 4 ++-- homeassistant/components/modbus/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 8453a4893fedd8..a2413c2e720aca 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -773,8 +773,8 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus/ @adamchengtkc @janiversen @vzahradnik -/tests/components/modbus/ @adamchengtkc @janiversen @vzahradnik +/homeassistant/components/modbus/ @janiversen +/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index b70055e5fbe6dc..7faf873b6551fc 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@adamchengtkc", "@janiversen", "@vzahradnik"], + "codeowners": ["@janiversen"], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], From ec6c374761b00482fd6b4197c28298534ff693d7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Mon, 18 Sep 2023 12:42:31 +0200 Subject: [PATCH 584/640] Clean up lyric sensor platform (#100495) * Clean up lyric sensor platform * Clean up lyric sensor platform * Clean up lyric sensor platform * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson * Update homeassistant/components/lyric/sensor.py Co-authored-by: Aidan Timson --------- Co-authored-by: Aidan Timson --- homeassistant/components/lyric/sensor.py | 206 ++++++++++------------- 1 file changed, 88 insertions(+), 118 deletions(-) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index d628a1081830ac..5bab1ffeb6f92c 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -4,7 +4,6 @@ from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta -from typing import cast from aiolyric import Lyric from aiolyric.objects.device import LyricDevice @@ -43,10 +42,84 @@ @dataclass -class LyricSensorEntityDescription(SensorEntityDescription): +class LyricSensorEntityDescriptionMixin: + """Mixin for required keys.""" + + value_fn: Callable[[LyricDevice], StateType | datetime] + suitable_fn: Callable[[LyricDevice], bool] + + +@dataclass +class LyricSensorEntityDescription( + SensorEntityDescription, LyricSensorEntityDescriptionMixin +): """Class describing Honeywell Lyric sensor entities.""" - value: Callable[[LyricDevice], StateType | datetime] = round + +DEVICE_SENSORS: list[LyricSensorEntityDescription] = [ + LyricSensorEntityDescription( + key="indoor_temperature", + translation_key="indoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.indoorTemperature, + suitable_fn=lambda device: device.indoorTemperature, + ), + LyricSensorEntityDescription( + key="indoor_humidity", + translation_key="indoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.indoorHumidity, + suitable_fn=lambda device: device.indoorHumidity, + ), + LyricSensorEntityDescription( + key="outdoor_temperature", + translation_key="outdoor_temperature", + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.outdoorTemperature, + suitable_fn=lambda device: device.outdoorTemperature, + ), + LyricSensorEntityDescription( + key="outdoor_humidity", + translation_key="outdoor_humidity", + device_class=SensorDeviceClass.HUMIDITY, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=PERCENTAGE, + value_fn=lambda device: device.displayedOutdoorHumidity, + suitable_fn=lambda device: device.displayedOutdoorHumidity, + ), + LyricSensorEntityDescription( + key="next_period_time", + translation_key="next_period_time", + device_class=SensorDeviceClass.TIMESTAMP, + value_fn=lambda device: get_datetime_from_future_time( + device.changeableValues.nextPeriodTime + ), + suitable_fn=lambda device: device.changeableValues + and device.changeableValues.nextPeriodTime, + ), + LyricSensorEntityDescription( + key="setpoint_status", + translation_key="setpoint_status", + icon="mdi:thermostat", + value_fn=lambda device: get_setpoint_status( + device.changeableValues.thermostatSetpointStatus, + device.changeableValues.nextPeriodTime, + ), + suitable_fn=lambda device: device.changeableValues + and device.changeableValues.thermostatSetpointStatus, + ), +] + + +def get_setpoint_status(status: str, time: str) -> str | None: + """Get status of the setpoint.""" + if status == PRESET_HOLD_UNTIL: + return f"Held until {time}" + return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) def get_datetime_from_future_time(time_str: str) -> datetime: @@ -68,129 +141,25 @@ async def async_setup_entry( entities = [] - def get_setpoint_status(status: str, time: str) -> str | None: - if status == PRESET_HOLD_UNTIL: - return f"Held until {time}" - return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) - for location in coordinator.data.locations: for device in location.devices: - if device.indoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_temperature", - translation_key="indoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.indoorTemperature, - ), - location, - device, - ) - ) - if device.indoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_indoor_humidity", - translation_key="indoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.indoorHumidity, - ), - location, - device, - ) - ) - if device.outdoorTemperature: - if device.units == "Fahrenheit": - native_temperature_unit = UnitOfTemperature.FAHRENHEIT - else: - native_temperature_unit = UnitOfTemperature.CELSIUS - - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_temperature", - translation_key="outdoor_temperature", - device_class=SensorDeviceClass.TEMPERATURE, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=native_temperature_unit, - value=lambda device: device.outdoorTemperature, - ), - location, - device, - ) - ) - if device.displayedOutdoorHumidity: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_outdoor_humidity", - translation_key="outdoor_humidity", - device_class=SensorDeviceClass.HUMIDITY, - state_class=SensorStateClass.MEASUREMENT, - native_unit_of_measurement=PERCENTAGE, - value=lambda device: device.displayedOutdoorHumidity, - ), - location, - device, - ) - ) - if device.changeableValues: - if device.changeableValues.nextPeriodTime: - entities.append( - LyricSensor( - coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_next_period_time", - translation_key="next_period_time", - device_class=SensorDeviceClass.TIMESTAMP, - value=lambda device: get_datetime_from_future_time( - device.changeableValues.nextPeriodTime - ), - ), - location, - device, - ) - ) - if device.changeableValues.thermostatSetpointStatus: + for device_sensor in DEVICE_SENSORS: + if device_sensor.suitable_fn(device): entities.append( LyricSensor( coordinator, - LyricSensorEntityDescription( - key=f"{device.macID}_setpoint_status", - translation_key="setpoint_status", - icon="mdi:thermostat", - value=lambda device: get_setpoint_status( - device.changeableValues.thermostatSetpointStatus, - device.changeableValues.nextPeriodTime, - ), - ), + device_sensor, location, device, ) ) - async_add_entities(entities, True) + async_add_entities(entities) class LyricSensor(LyricDeviceEntity, SensorEntity): """Define a Honeywell Lyric sensor.""" - coordinator: DataUpdateCoordinator[Lyric] entity_description: LyricSensorEntityDescription def __init__( @@ -205,15 +174,16 @@ def __init__( coordinator, location, device, - description.key, + f"{device.macID}_{description.key}", ) self.entity_description = description + if description.device_class == SensorDeviceClass.TEMPERATURE: + if device.units == "Fahrenheit": + self._attr_native_unit_of_measurement = UnitOfTemperature.FAHRENHEIT + else: + self._attr_native_unit_of_measurement = UnitOfTemperature.CELSIUS @property - def native_value(self) -> StateType: + def native_value(self) -> StateType | datetime: """Return the state.""" - device: LyricDevice = self.device - try: - return cast(StateType, self.entity_description.value(device)) - except TypeError: - return None + return self.entity_description.value_fn(self.device) From adf34bdf8bcedc5dbfe0d1d1eafe71875cf7a29a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Mon, 18 Sep 2023 12:56:35 +0200 Subject: [PATCH 585/640] Set co2signal integration type to service (#100543) --- homeassistant/components/co2signal/manifest.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/co2signal/manifest.json b/homeassistant/components/co2signal/manifest.json index 4ab4607cccce93..a4d7c55d6da207 100644 --- a/homeassistant/components/co2signal/manifest.json +++ b/homeassistant/components/co2signal/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@jpbede"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/co2signal", + "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["CO2Signal"], "requirements": ["CO2Signal==0.4.2"] diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index a65239316ed098..9fcb53894156e1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -863,7 +863,7 @@ }, "co2signal": { "name": "Electricity Maps", - "integration_type": "hub", + "integration_type": "service", "config_flow": true, "iot_class": "cloud_polling" }, From 49d742ce318efc7b1b50e0f045d7341165a36683 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Sep 2023 10:08:38 -0500 Subject: [PATCH 586/640] Drop codeowner for Magic Home/flux_led (#100557) --- CODEOWNERS | 4 ++-- homeassistant/components/flux_led/manifest.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index a2413c2e720aca..fd18e096b91748 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -400,8 +400,8 @@ build.json @home-assistant/supervisor /tests/components/flo/ @dmulcahey /homeassistant/components/flume/ @ChrisMandich @bdraco @jeeftor /tests/components/flume/ @ChrisMandich @bdraco @jeeftor -/homeassistant/components/flux_led/ @icemanch @bdraco -/tests/components/flux_led/ @icemanch @bdraco +/homeassistant/components/flux_led/ @icemanch +/tests/components/flux_led/ @icemanch /homeassistant/components/forecast_solar/ @klaasnicolaas @frenck /tests/components/forecast_solar/ @klaasnicolaas @frenck /homeassistant/components/forked_daapd/ @uvjustin diff --git a/homeassistant/components/flux_led/manifest.json b/homeassistant/components/flux_led/manifest.json index 977f6eefe07dbe..a55ae028342227 100644 --- a/homeassistant/components/flux_led/manifest.json +++ b/homeassistant/components/flux_led/manifest.json @@ -1,7 +1,7 @@ { "domain": "flux_led", "name": "Magic Home", - "codeowners": ["@icemanch", "@bdraco"], + "codeowners": ["@icemanch"], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -53,6 +53,5 @@ "documentation": "https://www.home-assistant.io/integrations/flux_led", "iot_class": "local_push", "loggers": ["flux_led"], - "quality_scale": "platinum", "requirements": ["flux-led==1.0.4"] } From fa1a1715c97844181a4eb7d5bc3f417cca0cbb42 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Mon, 18 Sep 2023 10:08:49 -0500 Subject: [PATCH 587/640] Drop codeowner for LIFX (#100556) --- CODEOWNERS | 2 -- homeassistant/components/lifx/manifest.json | 3 +-- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index fd18e096b91748..e985b6f20b4795 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -688,8 +688,6 @@ build.json @home-assistant/supervisor /tests/components/lidarr/ @tkdrob /homeassistant/components/life360/ @pnbruckner /tests/components/life360/ @pnbruckner -/homeassistant/components/lifx/ @bdraco -/tests/components/lifx/ @bdraco /homeassistant/components/light/ @home-assistant/core /tests/components/light/ @home-assistant/core /homeassistant/components/linux_battery/ @fabaff diff --git a/homeassistant/components/lifx/manifest.json b/homeassistant/components/lifx/manifest.json index d6b253bd4784a5..7cabfd4712f4d5 100644 --- a/homeassistant/components/lifx/manifest.json +++ b/homeassistant/components/lifx/manifest.json @@ -1,7 +1,7 @@ { "domain": "lifx", "name": "LIFX", - "codeowners": ["@bdraco"], + "codeowners": [], "config_flow": true, "dependencies": ["network"], "dhcp": [ @@ -39,7 +39,6 @@ }, "iot_class": "local_polling", "loggers": ["aiolifx", "aiolifx_effects", "bitstring"], - "quality_scale": "platinum", "requirements": [ "aiolifx==0.8.10", "aiolifx-effects==0.3.2", From ddd62a8f63bbddb456981842096d5116a54f2926 Mon Sep 17 00:00:00 2001 From: rappenze Date: Mon, 18 Sep 2023 20:22:23 +0200 Subject: [PATCH 588/640] Fibaro streamline hass.data entry (#100547) * Fibaro streamline hass.data entry * Fix tests --- homeassistant/components/fibaro/__init__.py | 11 ++--------- homeassistant/components/fibaro/binary_sensor.py | 7 +++---- homeassistant/components/fibaro/climate.py | 7 +++---- homeassistant/components/fibaro/cover.py | 10 +++------- homeassistant/components/fibaro/light.py | 10 +++------- homeassistant/components/fibaro/lock.py | 10 +++------- homeassistant/components/fibaro/scene.py | 10 +++------- homeassistant/components/fibaro/sensor.py | 8 +++++--- homeassistant/components/fibaro/switch.py | 10 +++------- tests/components/fibaro/conftest.py | 10 +++------- 10 files changed, 31 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/fibaro/__init__.py b/homeassistant/components/fibaro/__init__.py index 86f25253c2da21..ffa13749fa7034 100644 --- a/homeassistant/components/fibaro/__init__.py +++ b/homeassistant/components/fibaro/__init__.py @@ -35,8 +35,6 @@ _LOGGER = logging.getLogger(__name__) -FIBARO_CONTROLLER = "fibaro_controller" -FIBARO_DEVICES = "fibaro_devices" PLATFORMS = [ Platform.BINARY_SENSOR, Platform.CLIMATE, @@ -377,12 +375,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: except FibaroAuthFailed as auth_ex: raise ConfigEntryAuthFailed from auth_ex - data: dict[str, Any] = {} - hass.data.setdefault(DOMAIN, {})[entry.entry_id] = data - data[FIBARO_CONTROLLER] = controller - devices = data[FIBARO_DEVICES] = {} - for platform in PLATFORMS: - devices[platform] = [*controller.fibaro_devices[platform]] + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = controller # register the hub device info separately as the hub has sometimes no entities device_registry = dr.async_get(hass) @@ -408,7 +401,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: _LOGGER.debug("Shutting down Fibaro connection") unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - hass.data[DOMAIN][entry.entry_id][FIBARO_CONTROLLER].disable_state_handler() + hass.data[DOMAIN][entry.entry_id].disable_state_handler() hass.data[DOMAIN].pop(entry.entry_id) return unload_ok diff --git a/homeassistant/components/fibaro/binary_sensor.py b/homeassistant/components/fibaro/binary_sensor.py index 57b3bc99b4f402..07c0d9a779cd6c 100644 --- a/homeassistant/components/fibaro/binary_sensor.py +++ b/homeassistant/components/fibaro/binary_sensor.py @@ -16,7 +16,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN SENSOR_TYPES = { @@ -45,12 +45,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroBinarySensor(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.BINARY_SENSOR - ] + for device in controller.fibaro_devices[Platform.BINARY_SENSOR] ], True, ) diff --git a/homeassistant/components/fibaro/climate.py b/homeassistant/components/fibaro/climate.py index a56056ade03727..18fef8dbe7a3f6 100644 --- a/homeassistant/components/fibaro/climate.py +++ b/homeassistant/components/fibaro/climate.py @@ -21,7 +21,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PRESET_RESUME = "resume" @@ -113,12 +113,11 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ FibaroThermostat(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.CLIMATE - ] + for device in controller.fibaro_devices[Platform.CLIMATE] ], True, ) diff --git a/homeassistant/components/fibaro/cover.py b/homeassistant/components/fibaro/cover.py index c73c45d254c615..d353b352c5cb89 100644 --- a/homeassistant/components/fibaro/cover.py +++ b/homeassistant/components/fibaro/cover.py @@ -17,7 +17,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -27,13 +27,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro covers.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroCover(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.COVER - ] - ], + [FibaroCover(device) for device in controller.fibaro_devices[Platform.COVER]], True, ) diff --git a/homeassistant/components/fibaro/light.py b/homeassistant/components/fibaro/light.py index 6a918f64f86b21..981b81fdd4339e 100644 --- a/homeassistant/components/fibaro/light.py +++ b/homeassistant/components/fibaro/light.py @@ -23,7 +23,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN PARALLEL_UPDATES = 2 @@ -56,13 +56,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro controller devices.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLight(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LIGHT - ] - ], + [FibaroLight(device) for device in controller.fibaro_devices[Platform.LIGHT]], True, ) diff --git a/homeassistant/components/fibaro/lock.py b/homeassistant/components/fibaro/lock.py index 503407bc28f2bd..715116d2843c46 100644 --- a/homeassistant/components/fibaro/lock.py +++ b/homeassistant/components/fibaro/lock.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro locks.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroLock(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.LOCK - ] - ], + [FibaroLock(device) for device in controller.fibaro_devices[Platform.LOCK]], True, ) diff --git a/homeassistant/components/fibaro/scene.py b/homeassistant/components/fibaro/scene.py index 812a85b2f50eb3..36d2666f97dd74 100644 --- a/homeassistant/components/fibaro/scene.py +++ b/homeassistant/components/fibaro/scene.py @@ -13,7 +13,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import slugify -from . import FIBARO_DEVICES, FibaroController +from . import FibaroController from .const import DOMAIN @@ -23,13 +23,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Perform the setup for Fibaro scenes.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroScene(scene) - for scene in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SCENE - ] - ], + [FibaroScene(scene) for scene in controller.fibaro_devices[Platform.SCENE]], True, ) diff --git a/homeassistant/components/fibaro/sensor.py b/homeassistant/components/fibaro/sensor.py index b98e12b889e051..e859a9b1afbac0 100644 --- a/homeassistant/components/fibaro/sensor.py +++ b/homeassistant/components/fibaro/sensor.py @@ -26,7 +26,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.util import convert -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN # List of known sensors which represents a fibaro device @@ -107,7 +107,9 @@ async def async_setup_entry( """Set up the Fibaro controller devices.""" entities: list[SensorEntity] = [] - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][Platform.SENSOR]: + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] + + for device in controller.fibaro_devices[Platform.SENSOR]: entity_description = MAIN_SENSOR_TYPES.get(device.type) # main sensors are created even if the entity type is not known @@ -122,7 +124,7 @@ async def async_setup_entry( Platform.SENSOR, Platform.SWITCH, ): - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][platform]: + for device in controller.fibaro_devices[platform]: for entity_description in ADDITIONAL_SENSOR_TYPES: if entity_description.key in device.properties: entities.append(FibaroAdditionalSensor(device, entity_description)) diff --git a/homeassistant/components/fibaro/switch.py b/homeassistant/components/fibaro/switch.py index 6ca770ab2d1c5b..fdd473ea28259a 100644 --- a/homeassistant/components/fibaro/switch.py +++ b/homeassistant/components/fibaro/switch.py @@ -11,7 +11,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import FIBARO_DEVICES, FibaroDevice +from . import FibaroController, FibaroDevice from .const import DOMAIN @@ -21,13 +21,9 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Fibaro switches.""" + controller: FibaroController = hass.data[DOMAIN][entry.entry_id] async_add_entities( - [ - FibaroSwitch(device) - for device in hass.data[DOMAIN][entry.entry_id][FIBARO_DEVICES][ - Platform.SWITCH - ] - ], + [FibaroSwitch(device) for device in controller.fibaro_devices[Platform.SWITCH]], True, ) diff --git a/tests/components/fibaro/conftest.py b/tests/components/fibaro/conftest.py index 8a2bbcbcd4a68e..1a3f9b083b8686 100644 --- a/tests/components/fibaro/conftest.py +++ b/tests/components/fibaro/conftest.py @@ -5,7 +5,7 @@ from pyfibaro.fibaro_scene import SceneModel import pytest -from homeassistant.components.fibaro import DOMAIN, FIBARO_CONTROLLER, FIBARO_DEVICES +from homeassistant.components.fibaro import DOMAIN from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant @@ -47,16 +47,12 @@ async def setup_platform( controller_mock = Mock() controller_mock.hub_serial = "HC2-111111" controller_mock.get_room_name.return_value = room_name + controller_mock.fibaro_devices = {Platform.SCENE: scenes} for scene in scenes: scene.fibaro_controller = controller_mock - hass.data[DOMAIN] = { - config_entry.entry_id: { - FIBARO_CONTROLLER: controller_mock, - FIBARO_DEVICES: {Platform.SCENE: scenes}, - } - } + hass.data[DOMAIN] = {config_entry.entry_id: controller_mock} await hass.config_entries.async_forward_entry_setup(config_entry, platform) await hass.async_block_till_done() return config_entry From 37288d7788988bff9a09b55a05a4b678c0e6f6d5 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Mon, 18 Sep 2023 20:39:36 +0200 Subject: [PATCH 589/640] Add pylint plugin to check for calls to base implementation (#100432) --- .../components/airvisual/__init__.py | 1 + homeassistant/components/flo/switch.py | 1 + .../hunterdouglas_powerview/cover.py | 1 + .../hunterdouglas_powerview/sensor.py | 1 + .../hvv_departures/binary_sensor.py | 1 + homeassistant/components/isy994/sensor.py | 1 + homeassistant/components/isy994/switch.py | 1 + homeassistant/components/livisi/entity.py | 1 + .../components/lutron_caseta/binary_sensor.py | 1 + homeassistant/components/rflink/sensor.py | 1 + homeassistant/components/risco/entity.py | 1 + homeassistant/components/risco/sensor.py | 1 + homeassistant/components/shelly/entity.py | 2 + .../components/smart_meter_texas/sensor.py | 1 + .../components/tractive/device_tracker.py | 1 + pylint/plugins/hass_enforce_super_call.py | 79 +++++++ pyproject.toml | 1 + tests/pylint/conftest.py | 46 ++-- tests/pylint/test_enforce_super_call.py | 221 ++++++++++++++++++ 19 files changed, 349 insertions(+), 14 deletions(-) create mode 100644 pylint/plugins/hass_enforce_super_call.py create mode 100644 tests/pylint/test_enforce_super_call.py diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index 21be2e5d664a6b..8860db69b7916a 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -421,6 +421,7 @@ def __init__( self._entry = entry self.entity_description = description + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" diff --git a/homeassistant/components/flo/switch.py b/homeassistant/components/flo/switch.py index 18a4341db577f6..4456732d125fc8 100644 --- a/homeassistant/components/flo/switch.py +++ b/homeassistant/components/flo/switch.py @@ -100,6 +100,7 @@ def async_update_state(self) -> None: self._attr_is_on = self._device.last_known_valve_state == "open" self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove(self._device.async_add_listener(self.async_update_state)) diff --git a/homeassistant/components/hunterdouglas_powerview/cover.py b/homeassistant/components/hunterdouglas_powerview/cover.py index 833c1812ddbdfe..18fe1cd0a6988c 100644 --- a/homeassistant/components/hunterdouglas_powerview/cover.py +++ b/homeassistant/components/hunterdouglas_powerview/cover.py @@ -311,6 +311,7 @@ async def _async_force_refresh_state(self) -> None: await self.async_update() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hunterdouglas_powerview/sensor.py b/homeassistant/components/hunterdouglas_powerview/sensor.py index 825ca140f148e6..330e5dddfa5817 100644 --- a/homeassistant/components/hunterdouglas_powerview/sensor.py +++ b/homeassistant/components/hunterdouglas_powerview/sensor.py @@ -136,6 +136,7 @@ def native_value(self) -> int: """Get the current value in percentage.""" return self.entity_description.native_value_fn(self._shade) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/hvv_departures/binary_sensor.py b/homeassistant/components/hvv_departures/binary_sensor.py index 513c8dbd8b03b5..2eeb633921446e 100644 --- a/homeassistant/components/hvv_departures/binary_sensor.py +++ b/homeassistant/components/hvv_departures/binary_sensor.py @@ -192,6 +192,7 @@ def extra_state_attributes(self) -> dict[str, Any] | None: if v is not None } + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index b1899100dd4491..1a160024a65e48 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -262,6 +262,7 @@ def target_value(self) -> Any: """Return the target value.""" return None if self.target is None else self.target.value + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events. diff --git a/homeassistant/components/isy994/switch.py b/homeassistant/components/isy994/switch.py index 8467cba9e6a4ed..de64741ba3a295 100644 --- a/homeassistant/components/isy994/switch.py +++ b/homeassistant/components/isy994/switch.py @@ -156,6 +156,7 @@ def __init__( self._attr_name = description.name # Override super self._change_handler: EventListener = None + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Subscribe to the node control change events.""" self._change_handler = self._node.isy.nodes.status_events.subscribe( diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 5ddba1e2e86355..388788d3dea922 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -64,6 +64,7 @@ def __init__( ) super().__init__(coordinator) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" self.async_on_remove( diff --git a/homeassistant/components/lutron_caseta/binary_sensor.py b/homeassistant/components/lutron_caseta/binary_sensor.py index 334590c0e65c61..da7d6106796cc0 100644 --- a/homeassistant/components/lutron_caseta/binary_sensor.py +++ b/homeassistant/components/lutron_caseta/binary_sensor.py @@ -63,6 +63,7 @@ def is_on(self): """Return the brightness of the light.""" return self._device["status"] == OCCUPANCY_GROUP_OCCUPIED + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callbacks.""" self._smartbridge.add_occupancy_subscriber( diff --git a/homeassistant/components/rflink/sensor.py b/homeassistant/components/rflink/sensor.py index b96e03e7eb4297..fd6db8f0c6047a 100644 --- a/homeassistant/components/rflink/sensor.py +++ b/homeassistant/components/rflink/sensor.py @@ -352,6 +352,7 @@ def _handle_event(self, event): """Domain specific event handler.""" self._state = event["value"] + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register update callback.""" # Remove temporary bogus entity_id if added diff --git a/homeassistant/components/risco/entity.py b/homeassistant/components/risco/entity.py index 7f8e3be698b53f..f8869d75d4b30b 100644 --- a/homeassistant/components/risco/entity.py +++ b/homeassistant/components/risco/entity.py @@ -35,6 +35,7 @@ def _refresh_from_coordinator(self) -> None: self._get_data_from_coordinator() self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self.async_on_remove( diff --git a/homeassistant/components/risco/sensor.py b/homeassistant/components/risco/sensor.py index bb416b8c55098e..b196723afbe0ea 100644 --- a/homeassistant/components/risco/sensor.py +++ b/homeassistant/components/risco/sensor.py @@ -86,6 +86,7 @@ def __init__( self._attr_name = f"Risco {self.coordinator.risco.site_name} {name} Events" self._attr_device_class = SensorDeviceClass.TIMESTAMP + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to hass.""" self._entity_registry = er.async_get(self.hass) diff --git a/homeassistant/components/shelly/entity.py b/homeassistant/components/shelly/entity.py index 69dc6cb934011a..5afa5f8b7270a2 100644 --- a/homeassistant/components/shelly/entity.py +++ b/homeassistant/components/shelly/entity.py @@ -332,6 +332,7 @@ def __init__(self, coordinator: ShellyBlockCoordinator, block: Block) -> None: ) self._attr_unique_id = f"{coordinator.mac}-{block.description}" + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) @@ -375,6 +376,7 @@ def status(self) -> dict: """Device status by entity key.""" return cast(dict, self.coordinator.device.status[self.key]) + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """When entity is added to HASS.""" self.async_on_remove(self.coordinator.async_add_listener(self._update_callback)) diff --git a/homeassistant/components/smart_meter_texas/sensor.py b/homeassistant/components/smart_meter_texas/sensor.py index d237daf01caccc..84ad68fabc3628 100644 --- a/homeassistant/components/smart_meter_texas/sensor.py +++ b/homeassistant/components/smart_meter_texas/sensor.py @@ -73,6 +73,7 @@ def _state_update(self): 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.""" self.async_on_remove(self.coordinator.async_add_listener(self._state_update)) diff --git a/homeassistant/components/tractive/device_tracker.py b/homeassistant/components/tractive/device_tracker.py index 0e373e1a44fabf..00296f3108c04b 100644 --- a/homeassistant/components/tractive/device_tracker.py +++ b/homeassistant/components/tractive/device_tracker.py @@ -99,6 +99,7 @@ def _handle_position_update(self, event: dict[str, Any]) -> None: self._attr_available = True self.async_write_ha_state() + # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" if not self._client.subscribed: diff --git a/pylint/plugins/hass_enforce_super_call.py b/pylint/plugins/hass_enforce_super_call.py new file mode 100644 index 00000000000000..db4b2d4a5d7e59 --- /dev/null +++ b/pylint/plugins/hass_enforce_super_call.py @@ -0,0 +1,79 @@ +"""Plugin for checking super calls.""" +from __future__ import annotations + +from astroid import nodes +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.lint import PyLinter + +METHODS = { + "async_added_to_hass", +} + + +class HassEnforceSuperCallChecker(BaseChecker): # type: ignore[misc] + """Checker for super calls.""" + + name = "hass_enforce_super_call" + priority = -1 + msgs = { + "W7441": ( + "Missing call to: super().%s", + "hass-missing-super-call", + "Used when method should call its parent implementation.", + ), + } + options = () + + def visit_functiondef( + self, node: nodes.FunctionDef | nodes.AsyncFunctionDef + ) -> None: + """Check for super calls in method body.""" + if node.name not in METHODS: + return + + assert node.parent + parent = node.parent.frame() + if not isinstance(parent, nodes.ClassDef): + return + + # Check function body for super call + for child_node in node.body: + while isinstance(child_node, (nodes.Expr, nodes.Await, nodes.Return)): + child_node = child_node.value + match child_node: + case nodes.Call( + func=nodes.Attribute( + expr=nodes.Call(func=nodes.Name(name="super")), + attrname=node.name, + ), + ): + return + + # Check for non-empty base implementation + found_base_implementation = False + for base in parent.ancestors(): + for method in base.mymethods(): + if method.name != node.name: + continue + if method.body and not ( + len(method.body) == 1 and isinstance(method.body[0], nodes.Pass) + ): + found_base_implementation = True + break + + if found_base_implementation: + self.add_message( + "hass-missing-super-call", + node=node, + args=(node.name,), + confidence=INFERENCE, + ) + break + + visit_asyncfunctiondef = visit_functiondef + + +def register(linter: PyLinter) -> None: + """Register the checker.""" + linter.register_checker(HassEnforceSuperCallChecker(linter)) diff --git a/pyproject.toml b/pyproject.toml index a50ef040927558..7dfd584c59818f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -100,6 +100,7 @@ init-hook = """\ load-plugins = [ "pylint.extensions.code_style", "pylint.extensions.typing", + "hass_enforce_super_call", "hass_enforce_type_hints", "hass_inheritance", "hass_imports", diff --git a/tests/pylint/conftest.py b/tests/pylint/conftest.py index 4a53f686c5ae16..03f637a646fd46 100644 --- a/tests/pylint/conftest.py +++ b/tests/pylint/conftest.py @@ -11,13 +11,11 @@ BASE_PATH = Path(__file__).parents[2] -@pytest.fixture(name="hass_enforce_type_hints", scope="session") -def hass_enforce_type_hints_fixture() -> ModuleType: - """Fixture to provide a requests mocker.""" - module_name = "hass_enforce_type_hints" +def _load_plugin_from_file(module_name: str, file: str) -> ModuleType: + """Load plugin from file path.""" spec = spec_from_file_location( module_name, - str(BASE_PATH.joinpath("pylint/plugins/hass_enforce_type_hints.py")), + str(BASE_PATH.joinpath(file)), ) assert spec and spec.loader @@ -27,6 +25,15 @@ def hass_enforce_type_hints_fixture() -> ModuleType: return module +@pytest.fixture(name="hass_enforce_type_hints", scope="session") +def hass_enforce_type_hints_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_type_hints", + "pylint/plugins/hass_enforce_type_hints.py", + ) + + @pytest.fixture(name="linter") def linter_fixture() -> UnittestLinter: """Fixture to provide a requests mocker.""" @@ -44,16 +51,10 @@ def type_hint_checker_fixture(hass_enforce_type_hints, linter) -> BaseChecker: @pytest.fixture(name="hass_imports", scope="session") def hass_imports_fixture() -> ModuleType: """Fixture to provide a requests mocker.""" - module_name = "hass_imports" - spec = spec_from_file_location( - module_name, str(BASE_PATH.joinpath("pylint/plugins/hass_imports.py")) + return _load_plugin_from_file( + "hass_imports", + "pylint/plugins/hass_imports.py", ) - assert spec and spec.loader - - module = module_from_spec(spec) - sys.modules[module_name] = module - spec.loader.exec_module(module) - return module @pytest.fixture(name="imports_checker") @@ -62,3 +63,20 @@ def imports_checker_fixture(hass_imports, linter) -> BaseChecker: type_hint_checker = hass_imports.HassImportsFormatChecker(linter) type_hint_checker.module = "homeassistant.components.pylint_test" return type_hint_checker + + +@pytest.fixture(name="hass_enforce_super_call", scope="session") +def hass_enforce_super_call_fixture() -> ModuleType: + """Fixture to provide a requests mocker.""" + return _load_plugin_from_file( + "hass_enforce_super_call", + "pylint/plugins/hass_enforce_super_call.py", + ) + + +@pytest.fixture(name="super_call_checker") +def super_call_checker_fixture(hass_enforce_super_call, linter) -> BaseChecker: + """Fixture to provide a requests mocker.""" + super_call_checker = hass_enforce_super_call.HassEnforceSuperCallChecker(linter) + super_call_checker.module = "homeassistant.components.pylint_test" + return super_call_checker diff --git a/tests/pylint/test_enforce_super_call.py b/tests/pylint/test_enforce_super_call.py new file mode 100644 index 00000000000000..5e2861b1c74c4e --- /dev/null +++ b/tests/pylint/test_enforce_super_call.py @@ -0,0 +1,221 @@ +"""Tests for pylint hass_enforce_super_call plugin.""" +from __future__ import annotations + +from types import ModuleType +from unittest.mock import patch + +import astroid +from pylint.checkers import BaseChecker +from pylint.interfaces import INFERENCE +from pylint.testutils import MessageTest +from pylint.testutils.unittest_linter import UnittestLinter +from pylint.utils.ast_walker import ASTWalker +import pytest + +from . import assert_adds_messages, assert_no_messages + + +@pytest.mark.parametrize( + "code", + [ + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + pass + """, + id="no_parent", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"Some docstring.\"\"\" + pass + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 2 + """, + id="empty_parent_implementation2", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="correct_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + return await super().async_added_to_hass() + """, + id="super_call_in_return", + ), + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + super().added_to_hass() + """, + id="super_call_not_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + await super().async_added_to_hass() + """, + id="multiple_inheritance", + ), + pytest.param( + """ + async def async_added_to_hass() -> None: + x = 2 + """, + id="not_a_method", + ), + ], +) +def test_enforce_super_call( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, +) -> None: + """Good test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_no_messages(linter): + walker.walk(root_node) + + +@pytest.mark.parametrize( + ("code", "node_idx"), + [ + pytest.param( + """ + class Entity: + def added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + def added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 1, + id="no_super_call_async", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity): + async def async_added_to_hass(self) -> None: + await Entity.async_added_to_hass() + """, + 1, + id="explicit_call_to_base_implementation", + ), + pytest.param( + """ + class Entity: + async def async_added_to_hass(self) -> None: + \"\"\"\"\"\" + + class Coordinator: + async def async_added_to_hass(self) -> None: + x = 2 + + class Child(Entity, Coordinator): + async def async_added_to_hass(self) -> None: + x = 3 + """, + 2, + id="multiple_inheritance", + ), + ], +) +def test_enforce_super_call_bad( + linter: UnittestLinter, + hass_enforce_super_call: ModuleType, + super_call_checker: BaseChecker, + code: str, + node_idx: int, +) -> None: + """Bad test cases.""" + root_node = astroid.parse(code, "homeassistant.components.pylint_test") + walker = ASTWalker(linter) + walker.add_checker(super_call_checker) + node = root_node.body[node_idx].body[0] + + with patch.object( + hass_enforce_super_call, "METHODS", new={"added_to_hass", "async_added_to_hass"} + ), assert_adds_messages( + linter, + MessageTest( + msg_id="hass-missing-super-call", + node=node, + line=node.lineno, + args=(node.name,), + col_offset=node.col_offset, + end_line=node.position.end_lineno, + end_col_offset=node.position.end_col_offset, + confidence=INFERENCE, + ), + ): + walker.walk(root_node) From 2722e5ddaaf583adda9dce55197145b54097d08d Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Mon, 18 Sep 2023 21:31:04 +0200 Subject: [PATCH 590/640] Add Vodafone Station sensor platform (#99948) * Vodafone: add sensor platform * fix for model VOX30 v1 * fix, cleanup, 2 new sensors * apply review comments * apply last review comment --- .coveragerc | 1 + .../components/vodafone_station/__init__.py | 2 +- .../components/vodafone_station/const.py | 3 +- .../components/vodafone_station/sensor.py | 217 ++++++++++++++++++ .../components/vodafone_station/strings.json | 25 ++ 5 files changed, 246 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/vodafone_station/sensor.py diff --git a/.coveragerc b/.coveragerc index e226b22381b068..308546a5ebb3a2 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1462,6 +1462,7 @@ omit = homeassistant/components/vodafone_station/const.py homeassistant/components/vodafone_station/coordinator.py homeassistant/components/vodafone_station/device_tracker.py + homeassistant/components/vodafone_station/sensor.py homeassistant/components/volkszaehler/sensor.py homeassistant/components/volumio/__init__.py homeassistant/components/volumio/browse_media.py diff --git a/homeassistant/components/vodafone_station/__init__.py b/homeassistant/components/vodafone_station/__init__.py index c1cf23d974f72d..cf2a22d2dbc874 100644 --- a/homeassistant/components/vodafone_station/__init__.py +++ b/homeassistant/components/vodafone_station/__init__.py @@ -7,7 +7,7 @@ from .const import DOMAIN from .coordinator import VodafoneStationRouter -PLATFORMS = [Platform.DEVICE_TRACKER] +PLATFORMS = [Platform.DEVICE_TRACKER, Platform.SENSOR] async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: diff --git a/homeassistant/components/vodafone_station/const.py b/homeassistant/components/vodafone_station/const.py index 8d5a60afb60616..c4828e19951356 100644 --- a/homeassistant/components/vodafone_station/const.py +++ b/homeassistant/components/vodafone_station/const.py @@ -8,4 +8,5 @@ DEFAULT_DEVICE_NAME = "Unknown device" DEFAULT_HOST = "192.168.1.1" DEFAULT_USERNAME = "vodafone" -DEFAULT_SSL = True + +LINE_TYPES = ["dsl", "fiber", "internet_key"] diff --git a/homeassistant/components/vodafone_station/sensor.py b/homeassistant/components/vodafone_station/sensor.py new file mode 100644 index 00000000000000..0ca705ad56bdec --- /dev/null +++ b/homeassistant/components/vodafone_station/sensor.py @@ -0,0 +1,217 @@ +"""Vodafone Station sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Final + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfDataRate +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 homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.util.dt import utcnow + +from .const import _LOGGER, DOMAIN, LINE_TYPES +from .coordinator import VodafoneStationRouter + +NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] + + +@dataclass +class VodafoneStationBaseEntityDescription: + """Vodafone Station entity base description.""" + + value: Callable[[Any, Any], Any] = lambda val, key: val[key] + is_suitable: Callable[[dict], bool] = lambda val: True + + +@dataclass +class VodafoneStationEntityDescription( + VodafoneStationBaseEntityDescription, SensorEntityDescription +): + """Vodafone Station entity description.""" + + +def _calculate_uptime(value: dict, 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) + + +def _line_connection(value: dict, key: str) -> str | None: + """Identify line type.""" + + internet_ip = value[key] + dsl_ip = value.get("dsl_ipaddr") + fiber_ip = value.get("fiber_ipaddr") + internet_key_ip = value.get("vf_internet_key_ip_addr") + + if internet_ip == dsl_ip: + return LINE_TYPES[0] + + if internet_ip == fiber_ip: + return LINE_TYPES[1] + + if internet_ip == internet_key_ip: + return LINE_TYPES[2] + + return None + + +SENSOR_TYPES: Final = ( + VodafoneStationEntityDescription( + key="wan_ip4_addr", + translation_key="external_ipv4", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip4_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="wan_ip6_addr", + translation_key="external_ipv6", + icon="mdi:earth", + is_suitable=lambda info: info["wan_ip6_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="vf_internet_key_ip_addr", + translation_key="external_ip_key", + icon="mdi:earth", + is_suitable=lambda info: info["vf_internet_key_ip_addr"] not in NOT_AVAILABLE, + ), + VodafoneStationEntityDescription( + key="inter_ip_address", + translation_key="active_connection", + device_class=SensorDeviceClass.ENUM, + icon="mdi:wan", + options=LINE_TYPES, + value=_line_connection, + ), + VodafoneStationEntityDescription( + key="down_str", + translation_key="down_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="up_str", + translation_key="up_stream", + device_class=SensorDeviceClass.DATA_RATE, + native_unit_of_measurement=UnitOfDataRate.KILOBYTES_PER_SECOND, + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="fw_version", + translation_key="fw_version", + icon="mdi:new-box", + entity_category=EntityCategory.DIAGNOSTIC, + ), + VodafoneStationEntityDescription( + key="phone_num1", + translation_key="phone_num1", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable1"] == "0", + ), + VodafoneStationEntityDescription( + key="phone_num2", + translation_key="phone_num2", + icon="mdi:phone", + is_suitable=lambda info: info["phone_unavailable2"] == "0", + ), + VodafoneStationEntityDescription( + key="sys_uptime", + translation_key="sys_uptime", + device_class=SensorDeviceClass.TIMESTAMP, + entity_category=EntityCategory.DIAGNOSTIC, + value=_calculate_uptime, + ), + VodafoneStationEntityDescription( + key="sys_cpu_usage", + translation_key="sys_cpu_usage", + icon="mdi:chip", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_memory_usage", + translation_key="sys_memory_usage", + icon="mdi:memory", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value=lambda value, key: float(value[key][:-1]), + ), + VodafoneStationEntityDescription( + key="sys_reboot_cause", + translation_key="sys_reboot_cause", + icon="mdi:restart-alert", + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up entry.""" + _LOGGER.debug("Setting up Vodafone Station sensors") + + coordinator: VodafoneStationRouter = hass.data[DOMAIN][entry.entry_id] + + sensors_data = coordinator.data.sensors + + async_add_entities( + VodafoneStationSensorEntity(coordinator, sensor_descr) + for sensor_descr in SENSOR_TYPES + if sensor_descr.key in sensors_data and sensor_descr.is_suitable(sensors_data) + ) + + +class VodafoneStationSensorEntity( + CoordinatorEntity[VodafoneStationRouter], SensorEntity +): + """Representation of a Vodafone Station sensor.""" + + _attr_has_entity_name = True + entity_description: VodafoneStationEntityDescription + + def __init__( + self, + coordinator: VodafoneStationRouter, + description: VodafoneStationEntityDescription, + ) -> None: + """Initialize a Vodafone Station sensor.""" + super().__init__(coordinator) + + sensors_data = coordinator.data.sensors + serial_num = sensors_data["sys_serial_number"] + self.entity_description = description + + self._attr_device_info = DeviceInfo( + configuration_url=coordinator.api.base_url, + identifiers={(DOMAIN, serial_num)}, + name=f"Vodafone Station ({serial_num})", + manufacturer="Vodafone", + model=sensors_data.get("sys_model_name"), + hw_version=sensors_data["sys_hardware_version"], + sw_version=sensors_data["sys_firmware_version"], + ) + self._attr_unique_id = f"{serial_num}_{description.key}" + + @property + def native_value(self) -> StateType: + """Sensor value.""" + return self.entity_description.value( + self.coordinator.data.sensors, self.entity_description.key + ) diff --git a/homeassistant/components/vodafone_station/strings.json b/homeassistant/components/vodafone_station/strings.json index 3c452133c287aa..0c2a4a408dda8b 100644 --- a/homeassistant/components/vodafone_station/strings.json +++ b/homeassistant/components/vodafone_station/strings.json @@ -29,5 +29,30 @@ "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" } + }, + "entity": { + "sensor": { + "external_ipv4": { "name": "WAN IPv4 address" }, + "external_ipv6": { "name": "WAN IPv6 address" }, + "external_ip_key": { "name": "WAN internet key address" }, + "active_connection": { + "name": "Active connection", + "state": { + "unknown": "Unknown", + "dsl": "xDSL", + "fiber": "Fiber", + "internet_key": "Internet key" + } + }, + "down_stream": { "name": "WAN download rate" }, + "up_stream": { "name": "WAN upload rate" }, + "fw_version": { "name": "Firmware version" }, + "phone_num1": { "name": "Phone number (1)" }, + "phone_num2": { "name": "Phone number (2)" }, + "sys_uptime": { "name": "Uptime" }, + "sys_cpu_usage": { "name": "CPU usage" }, + "sys_memory_usage": { "name": "Memory usage" }, + "sys_reboot_cause": { "name": "Reboot cause" } + } } } From cf6eddee74eee8763fa603d5913540bae47bc0b3 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Sep 2023 09:45:56 +0200 Subject: [PATCH 591/640] Move uptimerobot coordinator to its own file (#100558) * Move uptimerobot coordinator to its own file * Fix import of coordinator in platforms --- .../components/uptimerobot/__init__.py | 72 +---------------- .../components/uptimerobot/binary_sensor.py | 2 +- .../components/uptimerobot/coordinator.py | 78 +++++++++++++++++++ .../components/uptimerobot/diagnostics.py | 2 +- .../components/uptimerobot/sensor.py | 2 +- .../components/uptimerobot/switch.py | 2 +- 6 files changed, 85 insertions(+), 73 deletions(-) create mode 100644 homeassistant/components/uptimerobot/coordinator.py diff --git a/homeassistant/components/uptimerobot/__init__.py b/homeassistant/components/uptimerobot/__init__.py index 3cb119837d72bd..58979d7defbff5 100644 --- a/homeassistant/components/uptimerobot/__init__.py +++ b/homeassistant/components/uptimerobot/__init__.py @@ -1,12 +1,7 @@ """The UptimeRobot integration.""" from __future__ import annotations -from pyuptimerobot import ( - UptimeRobot, - UptimeRobotAuthenticationException, - UptimeRobotException, - UptimeRobotMonitor, -) +from pyuptimerobot import UptimeRobot from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_API_KEY @@ -14,9 +9,9 @@ from homeassistant.exceptions import ConfigEntryAuthFailed 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 .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER, PLATFORMS +from .const import DOMAIN, PLATFORMS +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: @@ -51,64 +46,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): - """Data update coordinator for UptimeRobot.""" - - config_entry: ConfigEntry - - def __init__( - self, - hass: HomeAssistant, - config_entry_id: str, - dev_reg: dr.DeviceRegistry, - api: UptimeRobot, - ) -> None: - """Initialize coordinator.""" - super().__init__( - hass, - LOGGER, - name=DOMAIN, - update_interval=COORDINATOR_UPDATE_INTERVAL, - ) - self._config_entry_id = config_entry_id - self._device_registry = dev_reg - self.api = api - - async def _async_update_data(self) -> list[UptimeRobotMonitor]: - """Update data.""" - try: - response = await self.api.async_get_monitors() - except UptimeRobotAuthenticationException as exception: - raise ConfigEntryAuthFailed(exception) from exception - except UptimeRobotException as exception: - raise UpdateFailed(exception) from exception - - if response.status != API_ATTR_OK: - raise UpdateFailed(response.error.message) - - monitors: list[UptimeRobotMonitor] = response.data - - current_monitors = { - list(device.identifiers)[0][1] - for device in dr.async_entries_for_config_entry( - self._device_registry, self._config_entry_id - ) - } - new_monitors = {str(monitor.id) for monitor in monitors} - if stale_monitors := current_monitors - new_monitors: - for monitor_id in stale_monitors: - if device := self._device_registry.async_get_device( - identifiers={(DOMAIN, monitor_id)} - ): - self._device_registry.async_remove_device(device.id) - - # If there are new monitors, we should reload the config entry so we can - # create new devices and entities. - if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: - self.hass.async_create_task( - self.hass.config_entries.async_reload(self._config_entry_id) - ) - - return monitors diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index a4aeeb3151b1bc..2710d5166c20e4 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/coordinator.py b/homeassistant/components/uptimerobot/coordinator.py new file mode 100644 index 00000000000000..4c1d3ea2c78288 --- /dev/null +++ b/homeassistant/components/uptimerobot/coordinator.py @@ -0,0 +1,78 @@ +"""DataUpdateCoordinator for the uptimerobot integration.""" +from __future__ import annotations + +from pyuptimerobot import ( + UptimeRobot, + UptimeRobotAuthenticationException, + UptimeRobotException, + UptimeRobotMonitor, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_ATTR_OK, COORDINATOR_UPDATE_INTERVAL, DOMAIN, LOGGER + + +class UptimeRobotDataUpdateCoordinator(DataUpdateCoordinator[list[UptimeRobotMonitor]]): + """Data update coordinator for UptimeRobot.""" + + config_entry: ConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry_id: str, + dev_reg: dr.DeviceRegistry, + api: UptimeRobot, + ) -> None: + """Initialize coordinator.""" + super().__init__( + hass, + LOGGER, + name=DOMAIN, + update_interval=COORDINATOR_UPDATE_INTERVAL, + ) + self._config_entry_id = config_entry_id + self._device_registry = dev_reg + self.api = api + + async def _async_update_data(self) -> list[UptimeRobotMonitor]: + """Update data.""" + try: + response = await self.api.async_get_monitors() + except UptimeRobotAuthenticationException as exception: + raise ConfigEntryAuthFailed(exception) from exception + except UptimeRobotException as exception: + raise UpdateFailed(exception) from exception + + if response.status != API_ATTR_OK: + raise UpdateFailed(response.error.message) + + monitors: list[UptimeRobotMonitor] = response.data + + current_monitors = { + list(device.identifiers)[0][1] + for device in dr.async_entries_for_config_entry( + self._device_registry, self._config_entry_id + ) + } + new_monitors = {str(monitor.id) for monitor in monitors} + if stale_monitors := current_monitors - new_monitors: + for monitor_id in stale_monitors: + if device := self._device_registry.async_get_device( + identifiers={(DOMAIN, monitor_id)} + ): + self._device_registry.async_remove_device(device.id) + + # If there are new monitors, we should reload the config entry so we can + # create new devices and entities. + if self.data and new_monitors - {str(monitor.id) for monitor in self.data}: + self.hass.async_create_task( + self.hass.config_entries.async_reload(self._config_entry_id) + ) + + return monitors diff --git a/homeassistant/components/uptimerobot/diagnostics.py b/homeassistant/components/uptimerobot/diagnostics.py index 94710235ab751b..15173a5e43cd77 100644 --- a/homeassistant/components/uptimerobot/diagnostics.py +++ b/homeassistant/components/uptimerobot/diagnostics.py @@ -8,8 +8,8 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator async def async_get_config_entry_diagnostics( diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index f9d4097fe4034a..4ae40bf4134205 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -13,8 +13,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import DOMAIN +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 397d2085357f80..3406c9fe21a757 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -14,8 +14,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import UptimeRobotDataUpdateCoordinator from .const import API_ATTR_OK, DOMAIN, LOGGER +from .coordinator import UptimeRobotDataUpdateCoordinator from .entity import UptimeRobotEntity From f01c71e514866b30d8c31829b6b1e9293258d00d Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 11:40:05 +0200 Subject: [PATCH 592/640] Fix lyric feedback (#100586) --- homeassistant/components/lyric/sensor.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lyric/sensor.py b/homeassistant/components/lyric/sensor.py index 5bab1ffeb6f92c..f0a4cdfbb99e58 100644 --- a/homeassistant/components/lyric/sensor.py +++ b/homeassistant/components/lyric/sensor.py @@ -98,8 +98,9 @@ class LyricSensorEntityDescription( value_fn=lambda device: get_datetime_from_future_time( device.changeableValues.nextPeriodTime ), - suitable_fn=lambda device: device.changeableValues - and device.changeableValues.nextPeriodTime, + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.nextPeriodTime + ), ), LyricSensorEntityDescription( key="setpoint_status", @@ -109,8 +110,9 @@ class LyricSensorEntityDescription( device.changeableValues.thermostatSetpointStatus, device.changeableValues.nextPeriodTime, ), - suitable_fn=lambda device: device.changeableValues - and device.changeableValues.thermostatSetpointStatus, + suitable_fn=lambda device: ( + device.changeableValues and device.changeableValues.thermostatSetpointStatus + ), ), ] @@ -119,7 +121,7 @@ def get_setpoint_status(status: str, time: str) -> str | None: """Get status of the setpoint.""" if status == PRESET_HOLD_UNTIL: return f"Held until {time}" - return LYRIC_SETPOINT_STATUS_NAMES.get(status, None) + return LYRIC_SETPOINT_STATUS_NAMES.get(status) def get_datetime_from_future_time(time_str: str) -> datetime: From 11a90016d07c774fe2237e43d3d8624564fab950 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 12:08:13 +0200 Subject: [PATCH 593/640] Change Hue zigbee connectivity sensor into an enum (#98632) --- homeassistant/components/hue/strings.json | 10 ++++++++++ homeassistant/components/hue/v2/sensor.py | 8 ++++++++ 2 files changed, 18 insertions(+) diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index 326d08d1f7a3c2..1224abb240e4a9 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -94,6 +94,16 @@ } } } + }, + "sensor": { + "zigbee_connectivity": { + "state": { + "connected": "[%key:common::state::connected%]", + "disconnected": "[%key:common::state::disconnected%]", + "connectivity_issue": "Connectivity issue", + "unidirectional_incoming": "Unidirectional incoming" + } + } } }, "options": { diff --git a/homeassistant/components/hue/v2/sensor.py b/homeassistant/components/hue/v2/sensor.py index dcdae0a3294ee4..cc36edb88b2976 100644 --- a/homeassistant/components/hue/v2/sensor.py +++ b/homeassistant/components/hue/v2/sensor.py @@ -156,6 +156,14 @@ 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 @property From 2b8690d8bcd7ae4538c96c52f5153ab32fba89b4 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Tue, 19 Sep 2023 12:44:09 +0200 Subject: [PATCH 594/640] Remove platform const in co2signal coordinator (#100592) --- homeassistant/components/co2signal/coordinator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/co2signal/coordinator.py b/homeassistant/components/co2signal/coordinator.py index 2538e913a68647..dfb78326abe39b 100644 --- a/homeassistant/components/co2signal/coordinator.py +++ b/homeassistant/components/co2signal/coordinator.py @@ -9,7 +9,7 @@ import CO2Signal from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, Platform +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed @@ -18,7 +18,6 @@ from .exceptions import APIRatelimitExceeded, CO2Error, InvalidAuth, UnknownError from .models import CO2SignalResponse -PLATFORMS = [Platform.SENSOR] _LOGGER = logging.getLogger(__name__) From a2a62839bc5f9df482fea3ddd0996a970c0ad21f Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 19 Sep 2023 15:59:58 +0200 Subject: [PATCH 595/640] Add DataUpdateCoordinator to Minecraft Server (#100075) --- .coveragerc | 1 + .../components/minecraft_server/__init__.py | 221 ++---------------- .../minecraft_server/binary_sensor.py | 27 ++- .../minecraft_server/config_flow.py | 43 +++- .../components/minecraft_server/const.py | 2 - .../minecraft_server/coordinator.py | 93 ++++++++ .../components/minecraft_server/entity.py | 43 +--- .../components/minecraft_server/helpers.py | 38 +++ .../components/minecraft_server/sensor.py | 48 ++-- 9 files changed, 232 insertions(+), 284 deletions(-) create mode 100644 homeassistant/components/minecraft_server/coordinator.py create mode 100644 homeassistant/components/minecraft_server/helpers.py diff --git a/.coveragerc b/.coveragerc index 308546a5ebb3a2..73ae1d1a466014 100644 --- a/.coveragerc +++ b/.coveragerc @@ -735,6 +735,7 @@ omit = homeassistant/components/mill/sensor.py homeassistant/components/minecraft_server/__init__.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 diff --git a/homeassistant/components/minecraft_server/__init__.py b/homeassistant/components/minecraft_server/__init__.py index ee8bdbe2a3f299..b7326735be9bfc 100644 --- a/homeassistant/components/minecraft_server/__init__.py +++ b/homeassistant/components/minecraft_server/__init__.py @@ -1,31 +1,17 @@ """The Minecraft Server integration.""" from __future__ import annotations -from collections.abc import Mapping -from dataclasses import dataclass -from datetime import datetime, timedelta import logging from typing import Any -import aiodns -from mcstatus.server import JavaServer - from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, Platform -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.const import CONF_HOST, CONF_NAME, Platform +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_send import homeassistant.helpers.entity_registry as er -from homeassistant.helpers.event import async_track_time_interval -from .const import ( - DOMAIN, - KEY_LATENCY, - KEY_MOTD, - SCAN_INTERVAL, - SIGNAL_NAME_PREFIX, - SRV_RECORD_PREFIX, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD +from .coordinator import MinecraftServerCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -34,19 +20,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Minecraft Server from a config entry.""" - domain_data = hass.data.setdefault(DOMAIN, {}) - - # Create and store server instance. - config_entry_id = entry.entry_id _LOGGER.debug( - "Creating server instance for '%s' (%s)", + "Creating coordinator instance for '%s' (%s)", entry.data[CONF_NAME], entry.data[CONF_HOST], ) - server = MinecraftServer(hass, config_entry_id, entry.data) - domain_data[config_entry_id] = server - await server.async_update() - server.start_periodic_update() + + # Create coordinator instance. + config_entry_id = entry.entry_id + coordinator = MinecraftServerCoordinator(hass, config_entry_id, entry.data) + await coordinator.async_config_entry_first_refresh() + + # Store coordinator instance. + domain_data = hass.data.setdefault(DOMAIN, {}) + domain_data[config_entry_id] = coordinator # Set up platforms. await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) @@ -57,7 +44,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: """Unload Minecraft Server config entry.""" config_entry_id = config_entry.entry_id - server = hass.data[DOMAIN][config_entry_id] # Unload platforms. unload_ok = await hass.config_entries.async_unload_platforms( @@ -65,7 +51,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> ) # Clean up. - server.stop_periodic_update() hass.data[DOMAIN].pop(config_entry_id) return unload_ok @@ -165,181 +150,3 @@ def _migrate_entity_unique_id(entity_entry: er.RegistryEntry) -> dict[str, Any]: ) return {"new_unique_id": new_unique_id} - - -@dataclass -class MinecraftServerData: - """Representation of Minecraft server data.""" - - latency: float | None = None - motd: str | None = None - players_max: int | None = None - players_online: int | None = None - players_list: list[str] | None = None - protocol_version: int | None = None - version: str | None = None - - -class MinecraftServer: - """Representation of a Minecraft server.""" - - def __init__( - self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] - ) -> None: - """Initialize server instance.""" - self._hass = hass - - # Server data - self.unique_id = unique_id - self.name = config_data[CONF_NAME] - self.host = config_data[CONF_HOST] - self.port = config_data[CONF_PORT] - self.online = False - self._last_status_request_failed = False - self.srv_record_checked = False - - # 3rd party library instance - self._server = JavaServer(self.host, self.port) - - # Data provided by 3rd party library - self.data: MinecraftServerData = MinecraftServerData() - - # Dispatcher signal name - self.signal_name = f"{SIGNAL_NAME_PREFIX}_{self.unique_id}" - - # Callback for stopping periodic update. - self._stop_periodic_update: CALLBACK_TYPE | None = None - - def start_periodic_update(self) -> None: - """Start periodic execution of update method.""" - self._stop_periodic_update = async_track_time_interval( - self._hass, self.async_update, timedelta(seconds=SCAN_INTERVAL) - ) - - def stop_periodic_update(self) -> None: - """Stop periodic execution of update method.""" - if self._stop_periodic_update: - self._stop_periodic_update() - - async def async_check_connection(self) -> None: - """Check server connection using a 'status' request and store connection status.""" - # Check if host is a valid SRV record, if not already done. - if not self.srv_record_checked: - self.srv_record_checked = True - srv_record = await self._async_check_srv_record(self.host) - if srv_record is not None: - _LOGGER.debug( - "'%s' is a valid Minecraft SRV record ('%s:%s')", - self.host, - srv_record[CONF_HOST], - srv_record[CONF_PORT], - ) - # Overwrite host, port and 3rd party library instance - # with data extracted out of SRV record. - self.host = srv_record[CONF_HOST] - self.port = srv_record[CONF_PORT] - self._server = JavaServer(self.host, self.port) - - # Ping the server with a status request. - try: - await self._server.async_status() - self.online = True - except OSError as error: - _LOGGER.debug( - ( - "Error occurred while trying to check the connection to '%s:%s' -" - " OSError: %s" - ), - self.host, - self.port, - error, - ) - self.online = False - - async def _async_check_srv_record(self, host: str) -> dict[str, Any] | None: - """Check if the given host is a valid Minecraft SRV record.""" - srv_record = None - srv_query = None - - try: - srv_query = await aiodns.DNSResolver().query( - host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" - ) - except aiodns.error.DNSError: - # 'host' is not a SRV record. - pass - else: - # 'host' is a valid SRV record, extract the data. - srv_record = { - CONF_HOST: srv_query[0].host, - CONF_PORT: srv_query[0].port, - } - - return srv_record - - async def async_update(self, now: datetime | None = None) -> None: - """Get server data from 3rd party library and update properties.""" - # Check connection status. - server_online_old = self.online - await self.async_check_connection() - server_online = self.online - - # Inform user once about connection state changes if necessary. - if server_online_old and not server_online: - _LOGGER.warning("Connection to '%s:%s' lost", self.host, self.port) - elif not server_online_old and server_online: - _LOGGER.info("Connection to '%s:%s' (re-)established", self.host, self.port) - - # Update the server properties if server is online. - if server_online: - await self._async_status_request() - - # Notify sensors about new data. - async_dispatcher_send(self._hass, self.signal_name) - - async def _async_status_request(self) -> None: - """Request server status and update properties.""" - try: - status_response = await self._server.async_status() - - # Got answer to request, update properties. - self.data.version = status_response.version.name - self.data.protocol_version = status_response.version.protocol - self.data.players_online = status_response.players.online - self.data.players_max = status_response.players.max - self.data.latency = status_response.latency - self.data.motd = status_response.motd.to_plain() - - self.data.players_list = [] - if status_response.players.sample is not None: - for player in status_response.players.sample: - self.data.players_list.append(player.name) - self.data.players_list.sort() - - # Inform user once about successful update if necessary. - if self._last_status_request_failed: - _LOGGER.info( - "Updating the properties of '%s:%s' succeeded again", - self.host, - self.port, - ) - self._last_status_request_failed = False - except OSError as error: - # No answer to request, set all properties to unknown. - self.data.version = None - self.data.protocol_version = None - self.data.players_online = None - self.data.players_max = None - self.data.latency = None - self.data.players_list = None - self.data.motd = None - - # Inform user once about failed update if necessary. - if not self._last_status_request_failed: - _LOGGER.warning( - "Updating the properties of '%s:%s' failed - OSError: %s", - self.host, - self.port, - error, - ) - self._last_status_request_failed = True diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 51978d388b6e9e..0446e0a2d7cf6b 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -10,8 +10,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import MinecraftServer from .const import DOMAIN, ICON_STATUS, KEY_STATUS +from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity @@ -36,15 +36,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server binary sensor platform.""" - server = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add binary sensor entities. async_add_entities( [ - MinecraftServerBinarySensorEntity(server, description) + MinecraftServerBinarySensorEntity(coordinator, description) for description in BINARY_SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -55,15 +54,21 @@ class MinecraftServerBinarySensorEntity(MinecraftServerEntity, BinarySensorEntit def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerBinarySensorEntityDescription, ) -> None: """Initialize binary sensor base entity.""" - super().__init__(server=server) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{server.unique_id}-{description.key}" + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" self._attr_is_on = False - async def async_update(self) -> None: - """Update binary sensor state.""" - self._attr_is_on = self._server.online + @property + def available(self) -> bool: + """Return binary sensor availability.""" + return True + + @property + def is_on(self) -> bool: + """Return binary sensor state.""" + return self.coordinator.last_update_success diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index cdb345df55c5c4..beacfde5b8e193 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -1,15 +1,19 @@ """Config flow for Minecraft Server integration.""" from contextlib import suppress +import logging +from mcstatus import JavaServer import voluptuous as vol from homeassistant.config_entries import ConfigFlow from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT from homeassistant.data_entry_flow import FlowResult -from . import MinecraftServer +from . import helpers from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +_LOGGER = logging.getLogger(__name__) + class MinecraftServerConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Minecraft Server.""" @@ -52,16 +56,14 @@ async def async_step_user(self, user_input=None) -> FlowResult: CONF_HOST: host, CONF_PORT: port, } - server = MinecraftServer(self.hass, "dummy_unique_id", config_data) - await server.async_check_connection() - if not server.online: - # Host or port invalid or server not reachable. - errors["base"] = "cannot_connect" - else: + if await self._async_is_server_online(host, port): # Configuration data are available and no error was detected, # create configuration entry. return self.async_create_entry(title=title, data=config_data) + # Host or port invalid or server not reachable. + errors["base"] = "cannot_connect" + # Show configuration form (default form in case of no user_input, # form filled with user_input and eventually with errors otherwise). return self._show_config_form(user_input, errors) @@ -85,3 +87,30 @@ def _show_config_form(self, user_input=None, errors=None) -> FlowResult: ), errors=errors, ) + + async def _async_is_server_online(self, host: str, port: int) -> bool: + """Check server connection using a 'status' request and return result.""" + + # Check if host is a SRV record. If so, update server data. + if srv_record := await helpers.async_check_srv_record(host): + # Use extracted host and port from SRV record. + host = srv_record[CONF_HOST] + port = srv_record[CONF_PORT] + + # Send a status request to the server. + server = JavaServer(host, port) + 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" + ), + host, + port, + error, + ) + + return False diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index 5b59913c790c03..ea510c467a1443 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -28,8 +28,6 @@ SCAN_INTERVAL = 60 -SIGNAL_NAME_PREFIX = f"signal_{DOMAIN}" - SRV_RECORD_PREFIX = "_minecraft._tcp" UNIT_PLAYERS_MAX = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py new file mode 100644 index 00000000000000..6965759e734dcd --- /dev/null +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -0,0 +1,93 @@ +"""The Minecraft Server integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from dataclasses import dataclass +from datetime import timedelta +import logging +from typing import Any + +from mcstatus.server import JavaServer + +from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from . import helpers +from .const import SCAN_INTERVAL + +_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.""" + + _srv_record_checked = False + + def __init__( + self, hass: HomeAssistant, unique_id: str, config_data: Mapping[str, Any] + ) -> None: + """Initialize coordinator instance.""" + super().__init__( + hass=hass, + name=config_data[CONF_NAME], + logger=_LOGGER, + update_interval=timedelta(seconds=SCAN_INTERVAL), + ) + + # Server data + self.unique_id = unique_id + self._host = config_data[CONF_HOST] + self._port = config_data[CONF_PORT] + + # 3rd party library instance + self._server = JavaServer(self._host, self._port) + + async def _async_update_data(self) -> MinecraftServerData: + """Get server data from 3rd party library and update properties.""" + + # Check once if host is a valid Minecraft SRV record. + if not self._srv_record_checked: + self._srv_record_checked = True + if srv_record := await helpers.async_check_srv_record(self._host): + # Overwrite host, port and 3rd party library instance + # with data extracted out of the SRV record. + self._host = srv_record[CONF_HOST] + self._port = srv_record[CONF_PORT] + self._server = JavaServer(self._host, self._port) + + # Send status request to the server. + try: + status_response = await self._server.async_status() + except OSError as error: + raise UpdateFailed(error) from error + + # Got answer to request, update properties. + 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 4702b42beb9a95..e7e91c7be86c94 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -1,52 +1,27 @@ """Base entity for the Minecraft Server integration.""" - -from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers.device_registry import DeviceInfo -from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import MinecraftServer from .const import DOMAIN, MANUFACTURER +from .coordinator import MinecraftServerCoordinator -class MinecraftServerEntity(Entity): +class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" _attr_has_entity_name = True - _attr_should_poll = False def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, ) -> None: """Initialize base entity.""" - self._server = server + super().__init__(coordinator) self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, server.unique_id)}, + identifiers={(DOMAIN, coordinator.unique_id)}, manufacturer=MANUFACTURER, - model=f"Minecraft Server ({server.data.version})", - name=server.name, - sw_version=str(server.data.protocol_version), - ) - self._disconnect_dispatcher: CALLBACK_TYPE | None = None - - async def async_update(self) -> None: - """Fetch data from the server.""" - raise NotImplementedError() - - async def async_added_to_hass(self) -> None: - """Connect dispatcher to signal from server.""" - self._disconnect_dispatcher = async_dispatcher_connect( - self.hass, self._server.signal_name, self._update_callback + model=f"Minecraft Server ({coordinator.data.version})", + name=coordinator.name, + sw_version=str(coordinator.data.protocol_version), ) - - async def async_will_remove_from_hass(self) -> None: - """Disconnect dispatcher before removal.""" - if self._disconnect_dispatcher: - self._disconnect_dispatcher() - - @callback - def _update_callback(self) -> None: - """Triggers update of properties after receiving signal from server.""" - self.async_schedule_update_ha_state(force_refresh=True) diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py new file mode 100644 index 00000000000000..ac9ec52f679014 --- /dev/null +++ b/homeassistant/components/minecraft_server/helpers.py @@ -0,0 +1,38 @@ +"""Helper functions of Minecraft Server integration.""" +import logging +from typing import Any + +import aiodns + +from homeassistant.const import CONF_HOST, CONF_PORT + +from .const import SRV_RECORD_PREFIX + +_LOGGER = logging.getLogger(__name__) + + +async def async_check_srv_record(host: str) -> dict[str, Any] | None: + """Check if the given host is a valid Minecraft SRV record.""" + srv_record = None + + try: + srv_query = await aiodns.DNSResolver().query( + host=f"{SRV_RECORD_PREFIX}.{host}", qtype="SRV" + ) + except aiodns.error.DNSError: + # 'host' is not a Minecraft SRV record. + pass + else: + # 'host' is a valid Minecraft SRV record, extract the data. + srv_record = { + CONF_HOST: srv_query[0].host, + CONF_PORT: srv_query[0].port, + } + _LOGGER.debug( + "'%s' is a valid Minecraft SRV record ('%s:%s')", + host, + srv_record[CONF_HOST], + srv_record[CONF_PORT], + ) + + return srv_record diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index cb3be3e58d78fd..27749e5b60f78c 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -8,11 +8,10 @@ from homeassistant.components.sensor import SensorEntity, SensorEntityDescription from homeassistant.config_entries import ConfigEntry from homeassistant.const import UnitOfTime -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from . import MinecraftServer, MinecraftServerData from .const import ( ATTR_PLAYERS_LIST, DOMAIN, @@ -31,6 +30,7 @@ UNIT_PLAYERS_MAX, UNIT_PLAYERS_ONLINE, ) +from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity @@ -118,15 +118,14 @@ async def async_setup_entry( async_add_entities: AddEntitiesCallback, ) -> None: """Set up the Minecraft Server sensor platform.""" - server = hass.data[DOMAIN][config_entry.entry_id] + coordinator = hass.data[DOMAIN][config_entry.entry_id] # Add sensor entities. async_add_entities( [ - MinecraftServerSensorEntity(server, description) + MinecraftServerSensorEntity(coordinator, description) for description in SENSOR_DESCRIPTIONS - ], - True, + ] ) @@ -137,24 +136,27 @@ class MinecraftServerSensorEntity(MinecraftServerEntity, SensorEntity): def __init__( self, - server: MinecraftServer, + coordinator: MinecraftServerCoordinator, description: MinecraftServerSensorEntityDescription, ) -> None: """Initialize sensor base entity.""" - super().__init__(server) + super().__init__(coordinator) self.entity_description = description - self._attr_unique_id = f"{server.unique_id}-{description.key}" - - @property - def available(self) -> bool: - """Return sensor availability.""" - return self._server.online - - async def async_update(self) -> None: - """Update sensor state.""" - self._attr_native_value = self.entity_description.value_fn(self._server.data) - - if self.entity_description.attributes_fn: - self._attr_extra_state_attributes = self.entity_description.attributes_fn( - self._server.data - ) + self._attr_unique_id = f"{coordinator.unique_id}-{description.key}" + self._update_properties() + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._update_properties() + self.async_write_ha_state() + + @callback + def _update_properties(self) -> None: + """Update sensor properties.""" + self._attr_native_value = self.entity_description.value_fn( + self.coordinator.data + ) + + if func := self.entity_description.attributes_fn: + self._attr_extra_state_attributes = func(self.coordinator.data) From ea78f419a998146c7a0d35bba6cbbae6fc801868 Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Tue, 19 Sep 2023 10:35:23 -0400 Subject: [PATCH 596/640] Fix Roborock send command service calling not being enum (#100574) --- homeassistant/components/roborock/device.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/roborock/device.py b/homeassistant/components/roborock/device.py index 27f25208a4e9a8..2b005ecade6911 100644 --- a/homeassistant/components/roborock/device.py +++ b/homeassistant/components/roborock/device.py @@ -40,7 +40,7 @@ def get_cache(self, attribute: CacheableAttribute) -> AttributeCache: async def send( self, - command: RoborockCommand, + command: RoborockCommand | str, params: dict[str, Any] | list[Any] | int | None = None, ) -> dict: """Send a command to a vacuum cleaner.""" @@ -48,7 +48,7 @@ async def send( response = await self._api.send_command(command, params) except RoborockException as err: raise HomeAssistantError( - f"Error while calling {command.name} with {params}" + f"Error while calling {command.name if isinstance(command, RoborockCommand) else command} with {params}" ) from err return response From d9227a7e3d74a09a8cef2b94d632d368b71b8a6f Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 16:43:00 +0200 Subject: [PATCH 597/640] Add Spotify code owner (#100597) --- CODEOWNERS | 4 ++-- homeassistant/components/spotify/manifest.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index e985b6f20b4795..b3d2889b1088fa 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1191,8 +1191,8 @@ build.json @home-assistant/supervisor /homeassistant/components/spider/ @peternijssen /tests/components/spider/ @peternijssen /homeassistant/components/splunk/ @Bre77 -/homeassistant/components/spotify/ @frenck -/tests/components/spotify/ @frenck +/homeassistant/components/spotify/ @frenck @joostlek +/tests/components/spotify/ @frenck @joostlek /homeassistant/components/sql/ @gjohansson-ST @dougiteixeira /tests/components/sql/ @gjohansson-ST @dougiteixeira /homeassistant/components/squeezebox/ @rajlaud diff --git a/homeassistant/components/spotify/manifest.json b/homeassistant/components/spotify/manifest.json index 7ca1533744c4d0..84f2bc102e3b66 100644 --- a/homeassistant/components/spotify/manifest.json +++ b/homeassistant/components/spotify/manifest.json @@ -1,7 +1,7 @@ { "domain": "spotify", "name": "Spotify", - "codeowners": ["@frenck"], + "codeowners": ["@frenck", "@joostlek"], "config_flow": true, "dependencies": ["application_credentials"], "documentation": "https://www.home-assistant.io/integrations/spotify", From c3f74ae022a6a398650f5d934a8a9522da46445a Mon Sep 17 00:00:00 2001 From: Ian Date: Tue, 19 Sep 2023 08:10:29 -0700 Subject: [PATCH 598/640] Add config-flow to NextBus (#92149) --- homeassistant/components/nextbus/__init__.py | 19 +- .../components/nextbus/config_flow.py | 236 ++++++++++++++++++ .../components/nextbus/manifest.json | 1 + homeassistant/components/nextbus/sensor.py | 98 ++++---- homeassistant/components/nextbus/strings.json | 33 +++ homeassistant/components/nextbus/util.py | 2 +- homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 4 +- tests/components/nextbus/conftest.py | 36 +++ tests/components/nextbus/test_config_flow.py | 162 ++++++++++++ tests/components/nextbus/test_sensor.py | 211 ++++++++++------ tests/components/nextbus/test_util.py | 34 +++ 12 files changed, 705 insertions(+), 132 deletions(-) create mode 100644 homeassistant/components/nextbus/config_flow.py create mode 100644 homeassistant/components/nextbus/strings.json create mode 100644 tests/components/nextbus/conftest.py create mode 100644 tests/components/nextbus/test_config_flow.py create mode 100644 tests/components/nextbus/test_util.py diff --git a/homeassistant/components/nextbus/__init__.py b/homeassistant/components/nextbus/__init__.py index 4891af77b281b6..b582f82b929e5b 100644 --- a/homeassistant/components/nextbus/__init__.py +++ b/homeassistant/components/nextbus/__init__.py @@ -1 +1,18 @@ -"""NextBus sensor.""" +"""NextBus platform.""" + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up platforms for NextBus.""" + 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) diff --git a/homeassistant/components/nextbus/config_flow.py b/homeassistant/components/nextbus/config_flow.py new file mode 100644 index 00000000000000..d7149bcc9f4657 --- /dev/null +++ b/homeassistant/components/nextbus/config_flow.py @@ -0,0 +1,236 @@ +"""Config flow to configure the Nextbus integration.""" +from collections import Counter +import logging + +from py_nextbus import NextBusClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +def _dict_to_select_selector(options: dict[str, str]) -> SelectSelector: + return SelectSelector( + SelectSelectorConfig( + options=sorted( + ( + SelectOptionDict(value=key, label=value) + for key, value in options.items() + ), + key=lambda o: o["label"], + ), + mode=SelectSelectorMode.DROPDOWN, + ) + ) + + +def _get_agency_tags(client: NextBusClient) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_agency_list()["agency"]} + + +def _get_route_tags(client: NextBusClient, agency_tag: str) -> dict[str, str]: + return {a["tag"]: a["title"] for a in client.get_route_list(agency_tag)["route"]} + + +def _get_stop_tags( + client: NextBusClient, agency_tag: str, route_tag: str +) -> dict[str, str]: + route_config = client.get_route_config(route_tag, agency_tag) + tags = {a["tag"]: a["title"] for a in route_config["route"]["stop"]} + title_counts = Counter(tags.values()) + + stop_directions: dict[str, str] = {} + for direction in route_config["route"]["direction"]: + for stop in direction["stop"]: + stop_directions[stop["tag"]] = direction["name"] + + # 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]})" + + return tags + + +def _validate_import( + client: NextBusClient, agency_tag: str, route_tag: str, stop_tag: str +) -> str | tuple[str, str, str]: + agency_tags = _get_agency_tags(client) + agency = agency_tags.get(agency_tag) + if not agency: + return "invalid_agency" + + route_tags = _get_route_tags(client, agency_tag) + route = route_tags.get(route_tag) + if not route: + return "invalid_route" + + stop_tags = _get_stop_tags(client, agency_tag, route_tag) + stop = stop_tags.get(stop_tag) + if not stop: + return "invalid_stop" + + return agency, route, stop + + +def _unique_id_from_data(data: dict[str, str]) -> str: + return f"{data[CONF_AGENCY]}_{data[CONF_ROUTE]}_{data[CONF_STOP]}" + + +class NextBusFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle Nextbus configuration.""" + + VERSION = 1 + + _agency_tags: dict[str, str] + _route_tags: dict[str, str] + _stop_tags: dict[str, str] + + def __init__(self): + """Initialize NextBus config flow.""" + self.data: dict[str, str] = {} + self._client = NextBusClient(output_format="json") + _LOGGER.info("Init new config flow") + + async def async_step_import(self, config_input: dict[str, str]) -> FlowResult: + """Handle import of config.""" + agency_tag = config_input[CONF_AGENCY] + route_tag = config_input[CONF_ROUTE] + stop_tag = config_input[CONF_STOP] + + validation_result = await self.hass.async_add_executor_job( + _validate_import, + self._client, + agency_tag, + route_tag, + stop_tag, + ) + if isinstance(validation_result, str): + return self.async_abort(reason=validation_result) + + data = { + CONF_AGENCY: agency_tag, + CONF_ROUTE: route_tag, + CONF_STOP: stop_tag, + CONF_NAME: config_input.get( + CONF_NAME, + f"{config_input[CONF_AGENCY]} {config_input[CONF_ROUTE]}", + ), + } + + await self.async_set_unique_id(_unique_id_from_data(data)) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=" ".join(validation_result), + data=data, + ) + + async def async_step_user( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Handle a flow initiated by the user.""" + return await self.async_step_agency(user_input) + + async def async_step_agency( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select agency.""" + if user_input is not None: + self.data[CONF_AGENCY] = user_input[CONF_AGENCY] + + return await self.async_step_route() + + self._agency_tags = await self.hass.async_add_executor_job( + _get_agency_tags, self._client + ) + + return self.async_show_form( + step_id="agency", + data_schema=vol.Schema( + { + vol.Required(CONF_AGENCY): _dict_to_select_selector( + self._agency_tags + ), + } + ), + ) + + async def async_step_route( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select route.""" + if user_input is not None: + self.data[CONF_ROUTE] = user_input[CONF_ROUTE] + + return await self.async_step_stop() + + self._route_tags = await self.hass.async_add_executor_job( + _get_route_tags, self._client, self.data[CONF_AGENCY] + ) + + return self.async_show_form( + step_id="route", + data_schema=vol.Schema( + { + vol.Required(CONF_ROUTE): _dict_to_select_selector( + self._route_tags + ), + } + ), + ) + + async def async_step_stop( + self, + user_input: dict[str, str] | None = None, + ) -> FlowResult: + """Select stop.""" + + if user_input is not None: + self.data[CONF_STOP] = user_input[CONF_STOP] + + await self.async_set_unique_id(_unique_id_from_data(self.data)) + self._abort_if_unique_id_configured() + + agency_tag = self.data[CONF_AGENCY] + route_tag = self.data[CONF_ROUTE] + stop_tag = self.data[CONF_STOP] + + agency_name = self._agency_tags[agency_tag] + route_name = self._route_tags[route_tag] + stop_name = self._stop_tags[stop_tag] + + return self.async_create_entry( + title=f"{agency_name} {route_name} {stop_name}", + data=self.data, + ) + + self._stop_tags = await self.hass.async_add_executor_job( + _get_stop_tags, + self._client, + self.data[CONF_AGENCY], + self.data[CONF_ROUTE], + ) + + return self.async_show_form( + step_id="stop", + data_schema=vol.Schema( + { + vol.Required(CONF_STOP): _dict_to_select_selector(self._stop_tags), + } + ), + ) diff --git a/homeassistant/components/nextbus/manifest.json b/homeassistant/components/nextbus/manifest.json index 4b8bd1a929420f..15eb9b4e2454c5 100644 --- a/homeassistant/components/nextbus/manifest.json +++ b/homeassistant/components/nextbus/manifest.json @@ -2,6 +2,7 @@ "domain": "nextbus", "name": "NextBus", "codeowners": ["@vividboarder"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/nextbus", "iot_class": "cloud_polling", "loggers": ["py_nextbus"], diff --git a/homeassistant/components/nextbus/sensor.py b/homeassistant/components/nextbus/sensor.py index b8f36e10fa1070..1582ec25ffef17 100644 --- a/homeassistant/components/nextbus/sensor.py +++ b/homeassistant/components/nextbus/sensor.py @@ -12,14 +12,16 @@ SensorDeviceClass, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant 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.util.dt import utc_from_timestamp -from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP +from .const import CONF_AGENCY, CONF_ROUTE, CONF_STOP, DOMAIN from .util import listify, maybe_first _LOGGER = logging.getLogger(__name__) @@ -34,59 +36,54 @@ ) -def validate_value(value_name, value, value_list): - """Validate tag value is in the list of items and logs error if not.""" - valid_values = {v["tag"]: v["title"] for v in value_list} - if value not in valid_values: - _LOGGER.error( - "Invalid %s tag `%s`. Please use one of the following: %s", - value_name, - value, - ", ".join(f"{title}: {tag}" for tag, title in valid_values.items()), +async def async_setup_platform( + hass: HomeAssistant, + config: ConfigType, + async_add_entities: AddEntitiesCallback, + discovery_info: DiscoveryInfoType | None = None, +) -> None: + """Initialize nextbus import from config.""" + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + is_fixable=False, + breaks_in_ha_version="2024.4.0", + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "NextBus", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config ) - return False - - return True - - -def validate_tags(client, agency, route, stop): - """Validate provided tags.""" - # Validate agencies - if not validate_value("agency", agency, client.get_agency_list()["agency"]): - return False - - # Validate the route - if not validate_value("route", route, client.get_route_list(agency)["route"]): - return False + ) - # Validate the stop - route_config = client.get_route_config(route, agency)["route"] - if not validate_value("stop", stop, route_config["stop"]): - return False - return True - - -def setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, - add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, + config: ConfigEntry, + async_add_entities: AddEntitiesCallback, ) -> None: """Load values from configuration and initialize the platform.""" - agency = config[CONF_AGENCY] - route = config[CONF_ROUTE] - stop = config[CONF_STOP] - name = config.get(CONF_NAME) - client = NextBusClient(output_format="json") - # Ensures that the tags provided are valid, also logs out valid values - if not validate_tags(client, agency, route, stop): - _LOGGER.error("Invalid config value(s)") - return + _LOGGER.debug(config.data) + + 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, + ) - add_entities([NextBusDepartureSensor(client, agency, route, stop, name)], True) + async_add_entities((sensor,), True) class NextBusDepartureSensor(SensorEntity): @@ -103,17 +100,14 @@ class NextBusDepartureSensor(SensorEntity): _attr_device_class = SensorDeviceClass.TIMESTAMP _attr_icon = "mdi:bus" - def __init__(self, client, agency, route, stop, name=None): + def __init__(self, client, unique_id, agency, route, stop, name): """Initialize sensor with all required config.""" self.agency = agency self.route = route self.stop = stop self._attr_extra_state_attributes = {} - - # Maybe pull a more user friendly name from the API here - self._attr_name = f"{agency} {route}" - if name: - self._attr_name = name + self._attr_unique_id = unique_id + self._attr_name = name self._client = client diff --git a/homeassistant/components/nextbus/strings.json b/homeassistant/components/nextbus/strings.json new file mode 100644 index 00000000000000..4f54ebf165652f --- /dev/null +++ b/homeassistant/components/nextbus/strings.json @@ -0,0 +1,33 @@ +{ + "title": "NextBus predictions", + "config": { + "step": { + "agency": { + "title": "Select metro agency", + "data": { + "agency": "Metro agency" + } + }, + "route": { + "title": "Select route", + "data": { + "route": "Route" + } + }, + "stop": { + "title": "Select stop", + "data": { + "stop": "Stop" + } + } + }, + "error": { + "invalid_agency": "The agency value selected is not valid", + "invalid_route": "The route value selected is not valid", + "invalid_stop": "The stop value selected is not valid" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/nextbus/util.py b/homeassistant/components/nextbus/util.py index c753c452546a04..73b3b400ff412e 100644 --- a/homeassistant/components/nextbus/util.py +++ b/homeassistant/components/nextbus/util.py @@ -17,7 +17,7 @@ def listify(maybe_list: Any) -> list[Any]: return [maybe_list] -def maybe_first(maybe_list: list[Any]) -> Any: +def maybe_first(maybe_list: list[Any] | None) -> Any: """Return the first item out of a list or returns back the input.""" if isinstance(maybe_list, list) and maybe_list: return maybe_list[0] diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 229682eff1d645..0d20e80317c3b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -301,6 +301,7 @@ "netatmo", "netgear", "nexia", + "nextbus", "nextcloud", "nextdns", "nfandroidtv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9fcb53894156e1..d1efd527b697dd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3712,9 +3712,8 @@ "supported_by": "overkiz" }, "nextbus": { - "name": "NextBus", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "nextcloud": { @@ -6798,6 +6797,7 @@ "mobile_app", "moehlenhoff_alpha2", "moon", + "nextbus", "nmap_tracker", "plant", "proximity", diff --git a/tests/components/nextbus/conftest.py b/tests/components/nextbus/conftest.py new file mode 100644 index 00000000000000..a38f3fd850e151 --- /dev/null +++ b/tests/components/nextbus/conftest.py @@ -0,0 +1,36 @@ +"""Test helpers for NextBus tests.""" +from unittest.mock import MagicMock + +import pytest + + +@pytest.fixture +def mock_nextbus_lists(mock_nextbus: MagicMock) -> MagicMock: + """Mock all list functions in nextbus to test validate logic.""" + instance = mock_nextbus.return_value + instance.get_agency_list.return_value = { + "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] + } + instance.get_route_list.return_value = { + "route": [{"tag": "F", "title": "F - Market & Wharves"}] + } + instance.get_route_config.return_value = { + "route": { + "stop": [ + {"tag": "5650", "title": "Market St & 7th St"}, + {"tag": "5651", "title": "Market St & 7th St"}, + ], + "direction": [ + { + "name": "Outbound", + "stop": [{"tag": "5650"}], + }, + { + "name": "Inbound", + "stop": [{"tag": "5651"}], + }, + ], + } + } + + return instance diff --git a/tests/components/nextbus/test_config_flow.py b/tests/components/nextbus/test_config_flow.py new file mode 100644 index 00000000000000..9f427757183595 --- /dev/null +++ b/tests/components/nextbus/test_config_flow.py @@ -0,0 +1,162 @@ +"""Test the NextBus config flow.""" +from collections.abc import Generator +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant import config_entries, setup +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + + +@pytest.fixture +def mock_setup_entry() -> Generator[MagicMock, None, None]: + """Create a mock for the nextbus component setup.""" + with patch( + "homeassistant.components.nextbus.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_nextbus() -> Generator[MagicMock, None, None]: + """Create a mock py_nextbus module.""" + with patch("homeassistant.components.nextbus.config_flow.NextBusClient") as client: + yield client + + +async def test_import_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test config is imported and component set up.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert ( + result.get("title") + == "San Francisco Muni F - Market & Wharves Market St & 7th St (Outbound)" + ) + assert result.get("data") == {CONF_NAME: "sf-muni F", **data} + + assert len(mock_setup_entry.mock_calls) == 1 + + # Check duplicate entries are aborted + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +@pytest.mark.parametrize( + ("override", "expected_reason"), + ( + ({CONF_AGENCY: "not muni"}, "invalid_agency"), + ({CONF_ROUTE: "not F"}, "invalid_route"), + ({CONF_STOP: "not 5650"}, "invalid_stop"), + ), +) +async def test_import_config_invalid( + hass: HomeAssistant, + mock_setup_entry: MagicMock, + mock_nextbus_lists: MagicMock, + override: dict[str, str], + expected_reason: str, +) -> None: + """Test user is redirected to user setup flow because they have invalid config.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + + data = { + CONF_AGENCY: "sf-muni", + CONF_ROUTE: "F", + CONF_STOP: "5650", + **override, + } + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=data, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.ABORT + assert result.get("reason") == expected_reason + + +async def test_user_config( + hass: HomeAssistant, mock_setup_entry: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "agency" + + # Select agency + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_AGENCY: "sf-muni", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == "form" + assert result.get("step_id") == "route" + + # Select route + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_ROUTE: "F", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.FORM + assert result.get("step_id") == "stop" + + # Select stop + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + CONF_STOP: "5650", + }, + ) + await hass.async_block_till_done() + + assert result.get("type") == FlowResultType.CREATE_ENTRY + assert result.get("data") == { + "agency": "sf-muni", + "route": "F", + "stop": "5650", + } + + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/nextbus/test_sensor.py b/tests/components/nextbus/test_sensor.py index 4884d04d3aa978..071dd95fe7bae3 100644 --- a/tests/components/nextbus/test_sensor.py +++ b/tests/components/nextbus/test_sensor.py @@ -1,15 +1,24 @@ """The tests for the nexbus sensor component.""" +from collections.abc import Generator from copy import deepcopy -from unittest.mock import patch +from unittest.mock import MagicMock, patch import pytest -import homeassistant.components.nextbus.sensor as nextbus -import homeassistant.components.sensor as sensor -from homeassistant.core import HomeAssistant +from homeassistant.components import sensor +from homeassistant.components.nextbus.const import ( + CONF_AGENCY, + CONF_ROUTE, + CONF_STOP, + DOMAIN, +) +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.setup import async_setup_component -from tests.common import assert_setup_component +from tests.common import MockConfigEntry VALID_AGENCY = "sf-muni" VALID_ROUTE = "F" @@ -17,24 +26,34 @@ VALID_AGENCY_TITLE = "San Francisco Muni" VALID_ROUTE_TITLE = "F-Market & Wharves" VALID_STOP_TITLE = "Market St & 7th St" -SENSOR_ID_SHORT = "sensor.sf_muni_f" +SENSOR_ID = "sensor.san_francisco_muni_f_market_wharves_market_st_7th_st" + +PLATFORM_CONFIG = { + sensor.DOMAIN: { + "platform": DOMAIN, + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, + }, +} + CONFIG_BASIC = { - "sensor": { - "platform": "nextbus", - "agency": VALID_AGENCY, - "route": VALID_ROUTE, - "stop": VALID_STOP, + DOMAIN: { + CONF_AGENCY: VALID_AGENCY, + CONF_ROUTE: VALID_ROUTE, + CONF_STOP: VALID_STOP, } } -CONFIG_INVALID_MISSING = {"sensor": {"platform": "nextbus"}} - BASIC_RESULTS = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": { "title": "Outbound", "prediction": [ @@ -48,24 +67,19 @@ } -async def assert_setup_sensor(hass, config, count=1): - """Set up the sensor and assert it's been created.""" - with assert_setup_component(count): - assert await async_setup_component(hass, sensor.DOMAIN, config) - await hass.async_block_till_done() - - @pytest.fixture -def mock_nextbus(): +def mock_nextbus() -> Generator[MagicMock, None, None]: """Create a mock py_nextbus module.""" with patch( - "homeassistant.components.nextbus.sensor.NextBusClient" - ) as NextBusClient: - yield NextBusClient + "homeassistant.components.nextbus.sensor.NextBusClient", + ) as client: + yield client @pytest.fixture -def mock_nextbus_predictions(mock_nextbus): +def mock_nextbus_predictions( + mock_nextbus: MagicMock, +) -> Generator[MagicMock, None, None]: """Create a mock of NextBusClient predictions.""" instance = mock_nextbus.return_value instance.get_predictions_for_multi_stops.return_value = BASIC_RESULTS @@ -73,63 +87,69 @@ def mock_nextbus_predictions(mock_nextbus): return instance.get_predictions_for_multi_stops -@pytest.fixture -def mock_nextbus_lists(mock_nextbus): - """Mock all list functions in nextbus to test validate logic.""" - instance = mock_nextbus.return_value - instance.get_agency_list.return_value = { - "agency": [{"tag": "sf-muni", "title": "San Francisco Muni"}] - } - instance.get_route_list.return_value = { - "route": [{"tag": "F", "title": "F - Market & Wharves"}] - } - instance.get_route_config.return_value = { - "route": {"stop": [{"tag": "5650", "title": "Market St & 7th St"}]} - } - +async def assert_setup_sensor( + hass: HomeAssistant, + config: dict[str, str], + expected_state=ConfigEntryState.LOADED, +) -> MockConfigEntry: + """Set up the sensor and assert it's been created.""" + config_entry = MockConfigEntry( + domain=DOMAIN, + data=config[DOMAIN], + title=f"{VALID_AGENCY_TITLE} {VALID_ROUTE_TITLE} {VALID_STOP_TITLE}", + unique_id=f"{VALID_AGENCY}_{VALID_ROUTE}_{VALID_STOP}", + ) + config_entry.add_to_hass(hass) -async def test_valid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Test that sensor is set up properly with valid config.""" - await assert_setup_sensor(hass, CONFIG_BASIC) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is expected_state -async def test_invalid_config( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists -) -> None: - """Checks that component is not setup when missing information.""" - await assert_setup_sensor(hass, CONFIG_INVALID_MISSING, count=0) + return config_entry -async def test_validate_tags( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists +async def test_legacy_yaml_setup( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, ) -> None: - """Test that additional validation against the API is successful.""" - # with self.subTest('Valid everything'): - assert nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, VALID_STOP) - # with self.subTest('Invalid agency'): - assert not nextbus.validate_tags( - mock_nextbus(), "not-valid", VALID_ROUTE, VALID_STOP + """Test config setup and yaml deprecation.""" + with patch( + "homeassistant.components.nextbus.config_flow.NextBusClient", + ) as NextBusClient: + NextBusClient.return_value.get_predictions_for_multi_stops.return_value = ( + BASIC_RESULTS + ) + await async_setup_component(hass, sensor.DOMAIN, PLATFORM_CONFIG) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue( + HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}" ) + assert issue - # with self.subTest('Invalid route'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, "0", VALID_STOP) - # with self.subTest('Invalid stop'): - assert not nextbus.validate_tags(mock_nextbus(), VALID_AGENCY, VALID_ROUTE, 0) +async def test_valid_config( + hass: HomeAssistant, mock_nextbus: MagicMock, mock_nextbus_lists: MagicMock +) -> None: + """Test that sensor is set up properly with valid config.""" + await assert_setup_sensor(hass, CONFIG_BASIC) async def test_verify_valid_state( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify all attributes are set from a valid response.""" await assert_setup_sensor(hass, CONFIG_BASIC) + mock_nextbus_predictions.assert_called_once_with( [{"stop_tag": VALID_STOP, "route_tag": VALID_ROUTE}], VALID_AGENCY ) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "2019-03-28T21:09:31+00:00" assert state.attributes["agency"] == VALID_AGENCY_TITLE @@ -140,14 +160,20 @@ async def test_verify_valid_state( async def test_message_dict( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a single dict message is rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": {"text": "Message"}, "direction": { "title": "Outbound", @@ -162,20 +188,26 @@ async def test_message_dict( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message" async def test_message_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": { "title": "Outbound", @@ -190,20 +222,26 @@ async def test_message_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.attributes["message"] == "Message 1 -- Message 2" async def test_direction_list( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a list of messages are rendered correctly.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "message": [{"text": "Message 1"}, {"text": "Message 2"}], "direction": [ { @@ -224,7 +262,7 @@ async def test_direction_list( await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "2019-03-28T21:09:31+00:00" assert state.attributes["agency"] == VALID_AGENCY_TITLE @@ -235,46 +273,67 @@ async def test_direction_list( async def test_custom_name( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify that a custom name can be set via config.""" config = deepcopy(CONFIG_BASIC) - config["sensor"]["name"] = "Custom Name" + config[DOMAIN][CONF_NAME] = "Custom Name" await assert_setup_sensor(hass, config) state = hass.states.get("sensor.custom_name") assert state is not None + assert state.name == "Custom Name" +@pytest.mark.parametrize( + "prediction_results", + ( + {}, + {"Error": "Failed"}, + ), +) async def test_no_predictions( - hass: HomeAssistant, mock_nextbus, mock_nextbus_predictions, mock_nextbus_lists + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_predictions: MagicMock, + mock_nextbus_lists: MagicMock, + prediction_results: dict[str, str], ) -> None: """Verify there are no exceptions when no predictions are returned.""" - mock_nextbus_predictions.return_value = {} + mock_nextbus_predictions.return_value = prediction_results await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" async def test_verify_no_upcoming( - hass: HomeAssistant, mock_nextbus, mock_nextbus_lists, mock_nextbus_predictions + hass: HomeAssistant, + mock_nextbus: MagicMock, + mock_nextbus_lists: MagicMock, + mock_nextbus_predictions: MagicMock, ) -> None: """Verify attributes are set despite no upcoming times.""" mock_nextbus_predictions.return_value = { "predictions": { "agencyTitle": VALID_AGENCY_TITLE, + "agencyTag": VALID_AGENCY, "routeTitle": VALID_ROUTE_TITLE, + "routeTag": VALID_ROUTE, "stopTitle": VALID_STOP_TITLE, + "stopTag": VALID_STOP, "direction": {"title": "Outbound", "prediction": []}, } } await assert_setup_sensor(hass, CONFIG_BASIC) - state = hass.states.get(SENSOR_ID_SHORT) + state = hass.states.get(SENSOR_ID) assert state is not None assert state.state == "unknown" assert state.attributes["upcoming"] == "No upcoming predictions" diff --git a/tests/components/nextbus/test_util.py b/tests/components/nextbus/test_util.py new file mode 100644 index 00000000000000..798171464e657d --- /dev/null +++ b/tests/components/nextbus/test_util.py @@ -0,0 +1,34 @@ +"""Test NextBus util functions.""" +from typing import Any + +import pytest + +from homeassistant.components.nextbus.util import listify, maybe_first + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ("foo", ["foo"]), + (["foo"], ["foo"]), + (None, []), + ), +) +def test_listify(input: Any, expected: list[Any]) -> None: + """Test input listification.""" + assert listify(input) == expected + + +@pytest.mark.parametrize( + ("input", "expected"), + ( + ([], []), + (None, None), + ("test", "test"), + (["test"], "test"), + (["test", "second"], "test"), + ), +) +def test_maybe_first(input: list[Any] | None, expected: Any) -> None: + """Test maybe getting the first thing from a list.""" + assert maybe_first(input) == expected From 7c4f08e6b3b66463dea126cd887dfb9a98297cf5 Mon Sep 17 00:00:00 2001 From: Teemu R Date: Tue, 19 Sep 2023 17:15:43 +0200 Subject: [PATCH 599/640] Fix xiaomi_miio button platform regression (#100527) --- homeassistant/components/xiaomi_miio/button.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/xiaomi_miio/button.py b/homeassistant/components/xiaomi_miio/button.py index 9ed9b780911cd4..e5e11b85e58dc5 100644 --- a/homeassistant/components/xiaomi_miio/button.py +++ b/homeassistant/components/xiaomi_miio/button.py @@ -169,8 +169,12 @@ def __init__(self, device, entry, unique_id, coordinator, description): async def async_press(self) -> None: """Press the button.""" method = getattr(self._device, self.entity_description.method_press) - await self._try_command( - self.entity_description.method_press_error_message, - method, - self.entity_description.method_press_params, - ) + params = self.entity_description.method_press_params + if params is not None: + await self._try_command( + self.entity_description.method_press_error_message, method, params + ) + else: + await self._try_command( + self.entity_description.method_press_error_message, method + ) From 2ad0fd1ce11a33ffc0333dadb158c0cd7f422607 Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Tue, 19 Sep 2023 11:30:38 -0400 Subject: [PATCH 600/640] Adjust hassfest.manifest based on config.action (#100577) --- script/hassfest/manifest.py | 14 +++++++++----- script/hassfest/model.py | 4 ++-- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 9323b8e86c0083..acdea23444dc73 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -366,15 +366,19 @@ def _sort_manifest_keys(key: str) -> str: return _SORT_KEYS.get(key, key) -def sort_manifest(integration: Integration) -> bool: +def sort_manifest(integration: Integration, config: Config) -> bool: """Sort manifest.""" keys = list(integration.manifest.keys()) if (keys_sorted := sorted(keys, key=_sort_manifest_keys)) != keys: manifest = {key: integration.manifest[key] for key in keys_sorted} - integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + if config.action == "generate": + integration.manifest_path.write_text(json.dumps(manifest, indent=2)) + text = "have been sorted" + else: + text = "are not sorted correctly" integration.add_error( "manifest", - "Manifest keys have been sorted: domain, name, then alphabetical order", + f"Manifest keys {text}: domain, name, then alphabetical order", ) return True return False @@ -387,9 +391,9 @@ def validate(integrations: dict[str, Integration], config: Config) -> None: for integration in integrations.values(): validate_manifest(integration, core_components_dir) if not integration.errors: - if sort_manifest(integration): + if sort_manifest(integration, config): manifests_resorted.append(integration.manifest_path) - if manifests_resorted: + if config.action == "generate" and manifests_resorted: subprocess.run( ["pre-commit", "run", "--hook-stage", "manual", "prettier", "--files"] + manifests_resorted, diff --git a/script/hassfest/model.py b/script/hassfest/model.py index e4f93c80e815f3..7df65b8221efd1 100644 --- a/script/hassfest/model.py +++ b/script/hassfest/model.py @@ -4,7 +4,7 @@ from dataclasses import dataclass, field import json import pathlib -from typing import Any +from typing import Any, Literal @dataclass @@ -26,7 +26,7 @@ class Config: specific_integrations: list[pathlib.Path] | None root: pathlib.Path - action: str + action: Literal["validate", "generate"] requirements: bool errors: list[Error] = field(default_factory=list) cache: dict[str, Any] = field(default_factory=dict) From 8dd3d6f989bf5c7604337376bf45bf7546aa6a11 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Tue, 19 Sep 2023 17:40:55 +0200 Subject: [PATCH 601/640] Call async added to hass super in Livisi (#100446) --- homeassistant/components/livisi/entity.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/livisi/entity.py b/homeassistant/components/livisi/entity.py index 388788d3dea922..b7b9bdc8521078 100644 --- a/homeassistant/components/livisi/entity.py +++ b/homeassistant/components/livisi/entity.py @@ -67,6 +67,7 @@ def __init__( # pylint: disable-next=hass-missing-super-call async def async_added_to_hass(self) -> None: """Register callback for reachability.""" + await super().async_added_to_hass() self.async_on_remove( async_dispatcher_connect( self.hass, From 0eca433004835cd68a36858d40d591fc6d381511 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 19 Sep 2023 18:58:46 +0200 Subject: [PATCH 602/640] Update zeroconf discovery to use IPAddress objects to avoid conversions (#100567) --- homeassistant/components/zeroconf/__init__.py | 55 +++++++++++++---- .../androidtv_remote/test_config_flow.py | 25 ++++---- tests/components/apple_tv/test_config_flow.py | 58 +++++++++--------- tests/components/awair/const.py | 6 +- tests/components/axis/test_config_flow.py | 21 +++---- tests/components/axis/test_device.py | 5 +- tests/components/baf/test_config_flow.py | 17 +++--- tests/components/blebox/test_config_flow.py | 17 +++--- tests/components/bond/test_config_flow.py | 59 ++++++++++--------- .../components/bosch_shc/test_config_flow.py | 9 +-- tests/components/brother/test_config_flow.py | 21 +++---- tests/components/daikin/test_config_flow.py | 5 +- tests/components/devolo_home_control/const.py | 14 +++-- tests/components/devolo_home_network/const.py | 14 +++-- tests/components/doorbird/test_config_flow.py | 25 ++++---- tests/components/elgato/test_config_flow.py | 21 +++---- .../enphase_envoy/test_config_flow.py | 21 +++---- tests/components/esphome/test_config_flow.py | 41 ++++++------- .../forked_daapd/test_config_flow.py | 25 ++++---- tests/components/freebox/test_config_flow.py | 5 +- .../components/gogogate2/test_config_flow.py | 21 +++---- tests/components/guardian/test_config_flow.py | 9 +-- .../homekit_controller/test_config_flow.py | 5 +- .../components/homewizard/test_config_flow.py | 25 ++++---- tests/components/hue/test_config_flow.py | 29 ++++----- .../test_config_flow.py | 10 ++-- tests/components/ipp/__init__.py | 10 ++-- tests/components/ipp/test_config_flow.py | 5 +- tests/components/kodi/util.py | 11 ++-- tests/components/lifx/test_config_flow.py | 13 ++-- tests/components/lookin/__init__.py | 5 +- tests/components/lookin/test_config_flow.py | 3 +- tests/components/loqed/test_config_flow.py | 5 +- .../lutron_caseta/test_config_flow.py | 17 +++--- .../modern_forms/test_config_flow.py | 17 +++--- tests/components/nam/test_config_flow.py | 5 +- tests/components/nanoleaf/test_config_flow.py | 9 +-- tests/components/netatmo/test_config_flow.py | 5 +- tests/components/nut/test_config_flow.py | 5 +- .../components/octoprint/test_config_flow.py | 9 +-- tests/components/overkiz/test_config_flow.py | 5 +- tests/components/plugwise/test_config_flow.py | 17 +++--- .../pure_energie/test_config_flow.py | 9 +-- tests/components/rachio/test_config_flow.py | 13 ++-- .../rainmachine/test_config_flow.py | 21 +++---- tests/components/roku/__init__.py | 6 +- tests/components/roomba/test_config_flow.py | 9 +-- .../components/samsungtv/test_config_flow.py | 13 ++-- tests/components/shelly/test_config_flow.py | 13 ++-- tests/components/smappee/test_config_flow.py | 33 ++++++----- tests/components/sonos/conftest.py | 5 +- tests/components/sonos/test_config_flow.py | 5 +- .../components/soundtouch/test_config_flow.py | 5 +- tests/components/spotify/test_config_flow.py | 5 +- .../synology_dsm/test_config_flow.py | 9 +-- .../system_bridge/test_config_flow.py | 9 +-- tests/components/tado/test_config_flow.py | 9 +-- tests/components/thread/test_config_flow.py | 5 +- tests/components/tradfri/test_config_flow.py | 25 ++++---- tests/components/vizio/const.py | 6 +- tests/components/vizio/test_config_flow.py | 3 +- tests/components/volumio/test_config_flow.py | 5 +- tests/components/wled/test_config_flow.py | 25 ++++---- .../xiaomi_aqara/test_config_flow.py | 13 ++-- .../xiaomi_miio/test_config_flow.py | 21 +++---- tests/components/yeelight/__init__.py | 5 +- tests/components/yeelight/test_config_flow.py | 17 +++--- tests/components/zeroconf/test_init.py | 3 + tests/components/zha/test_config_flow.py | 29 ++++----- tests/components/zwave_js/test_config_flow.py | 7 ++- tests/components/zwave_me/test_config_flow.py | 5 +- 71 files changed, 575 insertions(+), 462 deletions(-) diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index 085e720e3df9bc..bf0984d3989ef0 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -98,16 +98,43 @@ @dataclass(slots=True) class ZeroconfServiceInfo(BaseServiceInfo): - """Prepared info from mDNS entries.""" + """Prepared info from mDNS entries. - host: str - addresses: list[str] + The ip_address is the most recently updated address + that is not a link local or unspecified address. + + The ip_addresses are all addresses in order of most + recently updated to least recently updated. + + The host is the string representation of the ip_address. + + The addresses are the string representations of the + ip_addresses. + + It is recommended to use the ip_address to determine + the address to connect to as it will be the most + recently updated address that is not a link local + or unspecified address. + """ + + ip_address: IPv4Address | IPv6Address + ip_addresses: list[IPv4Address | IPv6Address] port: int | None hostname: str type: str name: str properties: dict[str, Any] + @property + def host(self) -> str: + """Return the host.""" + return _stringify_ip_address(self.ip_address) + + @property + def addresses(self) -> list[str]: + """Return the addresses.""" + return [_stringify_ip_address(ip_address) for ip_address in self.ip_addresses] + @bind_hass async def async_get_instance(hass: HomeAssistant) -> HaZeroconf: @@ -536,10 +563,8 @@ def async_get_homekit_discovery( return None -@lru_cache(maxsize=256) # matches to the cache in zeroconf itself -def _stringify_ip_address(ip_addr: IPv4Address | IPv6Address) -> str: - """Stringify an IP address.""" - return str(ip_addr) +# matches to the cache in zeroconf itself +_stringify_ip_address = lru_cache(maxsize=256)(str) def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: @@ -547,14 +572,18 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: # See https://ietf.org/rfc/rfc6763.html#section-6.4 and # https://ietf.org/rfc/rfc6763.html#section-6.5 for expected encodings # for property keys and values - if not (ip_addresses := service.ip_addresses_by_version(IPVersion.All)): + if not (maybe_ip_addresses := service.ip_addresses_by_version(IPVersion.All)): return None - host: str | None = None + if TYPE_CHECKING: + ip_addresses = cast(list[IPv4Address | IPv6Address], maybe_ip_addresses) + else: + ip_addresses = maybe_ip_addresses + ip_address: IPv4Address | IPv6Address | None = None for ip_addr in ip_addresses: if not ip_addr.is_link_local and not ip_addr.is_unspecified: - host = _stringify_ip_address(ip_addr) + ip_address = ip_addr break - if not host: + if not ip_address: return None # Service properties are always bytes if they are set from the network. @@ -571,8 +600,8 @@ def info_from_service(service: AsyncServiceInfo) -> ZeroconfServiceInfo | None: assert service.server is not None, "server cannot be none if there are addresses" return ZeroconfServiceInfo( - host=host, - addresses=[_stringify_ip_address(ip_addr) for ip_addr in ip_addresses], + ip_address=ip_address, + ip_addresses=ip_addresses, port=service.port, hostname=service.server, type=service.type, diff --git a/tests/components/androidtv_remote/test_config_flow.py b/tests/components/androidtv_remote/test_config_flow.py index a2792efb0f31a2..fb4bc829160afe 100644 --- a/tests/components/androidtv_remote/test_config_flow.py +++ b/tests/components/androidtv_remote/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Android TV Remote config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from androidtvremote2 import CannotConnect, ConnectionClosed, InvalidAuth @@ -431,8 +432,8 @@ async def test_zeroconf_flow_success( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -509,8 +510,8 @@ async def test_zeroconf_flow_cannot_connect( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -560,8 +561,8 @@ async def test_zeroconf_flow_pairing_invalid_auth( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -643,8 +644,8 @@ async def test_zeroconf_flow_already_configured_host_changed_reloads_entry( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -696,8 +697,8 @@ async def test_zeroconf_flow_already_configured_host_not_changed_no_reload_entry DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", @@ -729,8 +730,8 @@ async def test_zeroconf_flow_abort_if_mac_is_missing( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=host, - addresses=[host], + ip_address=ip_address(host), + ip_addresses=[ip_address(host)], port=6466, hostname=host, type="mock_type", diff --git a/tests/components/apple_tv/test_config_flow.py b/tests/components/apple_tv/test_config_flow.py index 6256d1dde9ca2c..513c21f7ce5e4e 100644 --- a/tests/components/apple_tv/test_config_flow.py +++ b/tests/components/apple_tv/test_config_flow.py @@ -1,5 +1,5 @@ """Test config flow.""" -from ipaddress import IPv4Address +from ipaddress import IPv4Address, ip_address from unittest.mock import ANY, patch from pyatv import exceptions @@ -21,8 +21,8 @@ from tests.common import MockConfigEntry DMAP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", @@ -32,8 +32,8 @@ RAOP_SERVICE = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_raop._tcp.local.", @@ -558,8 +558,8 @@ async def test_zeroconf_unsupported_service_aborts(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -579,8 +579,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", port=None, name="Kitchen", @@ -594,8 +594,8 @@ async def test_zeroconf_add_mrp_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, name="Kitchen", @@ -836,8 +836,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -859,8 +859,8 @@ async def test_zeroconf_abort_if_other_in_progress( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -885,8 +885,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -907,8 +907,8 @@ async def test_zeroconf_missing_device_during_protocol_resolve( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -943,8 +943,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -965,8 +965,8 @@ async def test_zeroconf_additional_protocol_resolve_failure( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1003,8 +1003,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_airplay._tcp.local.", @@ -1046,8 +1046,8 @@ async def test_zeroconf_pair_additionally_found_protocols( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", port=None, type="_mediaremotetv._tcp.local.", @@ -1158,8 +1158,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", port=None, type="_touch-able._tcp.local.", diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py index cead20d10afb52..f24eaeb971d821 100644 --- a/tests/components/awair/const.py +++ b/tests/components/awair/const.py @@ -1,5 +1,7 @@ """Constants used in Awair tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.const import CONF_ACCESS_TOKEN, CONF_HOST @@ -9,8 +11,8 @@ CLOUD_UNIQUE_ID = "foo@bar.com" LOCAL_UNIQUE_ID = "00:B0:D0:63:C2:26" ZEROCONF_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="192.0.2.5", - addresses=["192.0.2.5"], + ip_address=ip_address("192.0.2.5"), + ip_addresses=[ip_address("192.0.2.5")], hostname="mock_hostname", name="awair12345", port=None, diff --git a/tests/components/axis/test_config_flow.py b/tests/components/axis/test_config_flow.py index d535b4bcb1f610..06fad5329ea3fd 100644 --- a/tests/components/axis/test_config_flow.py +++ b/tests/components/axis/test_config_flow.py @@ -1,4 +1,5 @@ """Test Axis config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -294,8 +295,8 @@ async def test_reauth_flow_update_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], port=80, hostname=f"axis-{MAC.lower()}.local.", type="_axis-video._tcp.local.", @@ -377,8 +378,8 @@ async def test_discovery_flow( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=DEFAULT_HOST, - addresses=[DEFAULT_HOST], + ip_address=ip_address(DEFAULT_HOST), + ip_addresses=[ip_address(DEFAULT_HOST)], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, @@ -431,8 +432,8 @@ async def test_discovered_device_already_configured( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=8080, @@ -505,8 +506,8 @@ async def test_discovery_flow_updated_configuration( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="", - addresses=[""], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name="", port=0, @@ -554,8 +555,8 @@ async def test_discovery_flow_ignore_non_axis_device( ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host="169.254.3.4", - addresses=["169.254.3.4"], + ip_address=ip_address("169.254.3.4"), + ip_addresses=[ip_address("169.254.3.4")], hostname="mock_hostname", name=f"AXIS M1065-LW - {MAC}._axis-video._tcp.local.", port=80, diff --git a/tests/components/axis/test_device.py b/tests/components/axis/test_device.py index ef2cc7f448ad74..ff7ff343a06108 100644 --- a/tests/components/axis/test_device.py +++ b/tests/components/axis/test_device.py @@ -1,4 +1,5 @@ """Test Axis device.""" +from ipaddress import ip_address from unittest import mock from unittest.mock import Mock, patch @@ -117,8 +118,8 @@ async def test_update_address( await hass.config_entries.flow.async_init( AXIS_DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="2.3.4.5", - addresses=["2.3.4.5"], + ip_address=ip_address("2.3.4.5"), + ip_addresses=[ip_address("2.3.4.5")], hostname="mock_hostname", name="name", port=80, diff --git a/tests/components/baf/test_config_flow.py b/tests/components/baf/test_config_flow.py index 871e75f7c23c1d..f770db05096a36 100644 --- a/tests/components/baf/test_config_flow.py +++ b/tests/components/baf/test_config_flow.py @@ -1,5 +1,6 @@ """Test the baf config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -87,8 +88,8 @@ async def test_zeroconf_discovery(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -125,8 +126,8 @@ async def test_zeroconf_updates_existing_ip(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, @@ -145,8 +146,8 @@ async def test_zeroconf_rejects_ipv6(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="testfan", port=None, @@ -164,8 +165,8 @@ async def test_user_flow_is_not_blocked_by_discovery(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="testfan", port=None, diff --git a/tests/components/blebox/test_config_flow.py b/tests/components/blebox/test_config_flow.py index 0f2cfebd12e1fe..765f7af3f62a9c 100644 --- a/tests/components/blebox/test_config_flow.py +++ b/tests/components/blebox/test_config_flow.py @@ -1,4 +1,5 @@ """Test Home Assistant config flow for BleBox devices.""" +from ipaddress import ip_address from unittest.mock import DEFAULT, AsyncMock, PropertyMock, patch import blebox_uniapi @@ -211,8 +212,8 @@ async def test_flow_with_zeroconf(hass: HomeAssistant) -> None: config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -251,8 +252,8 @@ async def test_flow_with_zeroconf_when_already_configured(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -275,8 +276,8 @@ async def test_flow_with_zeroconf_when_device_unsupported(hass: HomeAssistant) - config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", @@ -301,8 +302,8 @@ async def test_flow_with_zeroconf_when_device_response_unsupported( config_flow.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="172.100.123.4", - addresses=["172.100.123.4"], + ip_address=ip_address("172.100.123.4"), + ip_addresses=[ip_address("172.100.123.4")], port=80, hostname="bbx-bbtest123456.local.", type="_bbxsrv._tcp.local.", diff --git a/tests/components/bond/test_config_flow.py b/tests/components/bond/test_config_flow.py index fab579a81a3cb7..91d628e4841e01 100644 --- a/tests/components/bond/test_config_flow.py +++ b/tests/components/bond/test_config_flow.py @@ -3,6 +3,7 @@ import asyncio from http import HTTPStatus +from ipaddress import ip_address from typing import Any from unittest.mock import MagicMock, Mock, patch @@ -203,8 +204,8 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -227,7 +228,7 @@ async def test_zeroconf_form(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -241,8 +242,8 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -264,7 +265,7 @@ async def test_zeroconf_form_token_unavailable(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -278,8 +279,8 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -301,7 +302,7 @@ async def test_zeroconf_form_token_times_out(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "bond-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "test-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -319,8 +320,8 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -342,7 +343,7 @@ async def test_zeroconf_form_with_token_available(hass: HomeAssistant) -> None: assert result2["type"] == "create_entry" assert result2["title"] == "discovered-name" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -360,8 +361,8 @@ async def test_zeroconf_form_with_token_available_name_unavailable( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, @@ -383,7 +384,7 @@ async def test_zeroconf_form_with_token_available_name_unavailable( assert result2["type"] == "create_entry" assert result2["title"] == "ZXXX12345" assert result2["data"] == { - CONF_HOST: "test-host", + CONF_HOST: "127.0.0.1", CONF_ACCESS_TOKEN: "discovered-token", } assert len(mock_setup_entry.mock_calls) == 1 @@ -404,8 +405,8 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -417,7 +418,7 @@ async def test_zeroconf_already_configured(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 @@ -442,8 +443,8 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -455,7 +456,7 @@ async def test_zeroconf_in_setup_retry_state(hass: HomeAssistant) -> None: assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert len(mock_setup_entry.mock_calls) == 1 assert entry.state is ConfigEntryState.LOADED @@ -488,8 +489,8 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="updated-host", - addresses=["updated-host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -501,7 +502,7 @@ async def test_zeroconf_already_configured_refresh_token(hass: HomeAssistant) -> assert result["type"] == "abort" assert result["reason"] == "already_configured" - assert entry.data["host"] == "updated-host" + assert entry.data["host"] == "127.0.0.2" assert entry.data[CONF_ACCESS_TOKEN] == "discovered-token" # entry2 should not get changed assert entry2.data[CONF_ACCESS_TOKEN] == "correct-token" @@ -515,7 +516,7 @@ async def test_zeroconf_already_configured_no_reload_same_host( entry = MockConfigEntry( domain=DOMAIN, unique_id="already-registered-bond-id", - data={CONF_HOST: "stored-host", CONF_ACCESS_TOKEN: "correct-token"}, + data={CONF_HOST: "127.0.0.3", CONF_ACCESS_TOKEN: "correct-token"}, ) entry.add_to_hass(hass) @@ -526,8 +527,8 @@ async def test_zeroconf_already_configured_no_reload_same_host( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="stored-host", - addresses=["stored-host"], + ip_address=ip_address("127.0.0.3"), + ip_addresses=[ip_address("127.0.0.3")], hostname="mock_hostname", name="already-registered-bond-id.some-other-tail-info", port=None, @@ -548,8 +549,8 @@ async def test_zeroconf_form_unexpected_error(hass: HomeAssistant) -> None: hass, source=config_entries.SOURCE_ZEROCONF, initial_input=zeroconf.ZeroconfServiceInfo( - host="test-host", - addresses=["test-host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="ZXXX12345.some-other-tail-info", port=None, diff --git a/tests/components/bosch_shc/test_config_flow.py b/tests/components/bosch_shc/test_config_flow.py index 92f49b86ef706b..e5d0abb3c9defb 100644 --- a/tests/components/bosch_shc/test_config_flow.py +++ b/tests/components/bosch_shc/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Bosch SHC config flow.""" +from ipaddress import ip_address from unittest.mock import PropertyMock, mock_open, patch from boschshcpy.exceptions import ( @@ -22,8 +23,8 @@ "device": {"mac": "test-mac", "hostname": "test-host"}, } DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="shc012345.local.", name="Bosch SHC [test-mac]._http._tcp.local.", port=0, @@ -548,8 +549,8 @@ async def test_zeroconf_not_bosch_shc(hass: HomeAssistant, mock_zeroconf: None) result = await hass.config_entries.flow.async_init( DOMAIN, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="notboschshc", port=None, diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index 629295e09e0260..f83f882b8a0205 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Brother Printer config flow.""" +from ipaddress import ip_address import json from unittest.mock import patch @@ -155,8 +156,8 @@ async def test_zeroconf_snmp_error(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -178,8 +179,8 @@ async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -210,8 +211,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -238,8 +239,8 @@ async def test_zeroconf_no_probe_existing_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, @@ -264,8 +265,8 @@ async def test_zeroconf_confirm_create_entry(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="Brother Printer", port=None, diff --git a/tests/components/daikin/test_config_flow.py b/tests/components/daikin/test_config_flow.py index 27c3b7d9ea35a9..4d54d7483df49a 100644 --- a/tests/components/daikin/test_config_flow.py +++ b/tests/components/daikin/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Daikin config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import PropertyMock, patch from aiohttp import ClientError, web_exceptions @@ -119,8 +120,8 @@ async def test_api_password_abort(hass: HomeAssistant) -> None: ( SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=HOST, - addresses=[HOST], + ip_address=ip_address(HOST), + ip_addresses=[ip_address(HOST)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_control/const.py b/tests/components/devolo_home_control/const.py index 96090195d20b77..3351e42c98836f 100644 --- a/tests/components/devolo_home_control/const.py +++ b/tests/components/devolo_home_control/const.py @@ -1,10 +1,12 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="192.168.0.1", - addresses=["192.168.0.1"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -21,8 +23,8 @@ ) DISCOVERY_INFO_WRONG_DEVOLO_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -31,8 +33,8 @@ ) DISCOVERY_INFO_WRONG_DEVICE = zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("192.168.0.1"), + ip_addresses=[ip_address("192.168.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/devolo_home_network/const.py b/tests/components/devolo_home_network/const.py index bc2ef2d87b20b9..8cf63cf07aea52 100644 --- a/tests/components/devolo_home_network/const.py +++ b/tests/components/devolo_home_network/const.py @@ -1,5 +1,7 @@ """Constants used for mocking data.""" +from ipaddress import ip_address + from devolo_plc_api.device_api import ( UPDATE_AVAILABLE, WIFI_BAND_2G, @@ -30,8 +32,8 @@ NO_CONNECTED_STATIONS = [] DISCOVERY_INFO = ZeroconfServiceInfo( - host=IP, - addresses=[IP], + ip_address=ip_address(IP), + ip_addresses=[ip_address(IP)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -51,8 +53,8 @@ ) DISCOVERY_INFO_CHANGED = ZeroconfServiceInfo( - host=IP_ALT, - addresses=[IP_ALT], + ip_address=ip_address(IP_ALT), + ip_addresses=[ip_address(IP_ALT)], port=14791, hostname="test.local.", type="_dvl-deviceapi._tcp.local.", @@ -72,8 +74,8 @@ ) DISCOVERY_INFO_WRONG_DEVICE = ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/doorbird/test_config_flow.py b/tests/components/doorbird/test_config_flow.py index e982f4ca172067..7ad7fbe07ace42 100644 --- a/tests/components/doorbird/test_config_flow.py +++ b/tests/components/doorbird/test_config_flow.py @@ -1,4 +1,5 @@ """Test the DoorBird config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, Mock, patch import pytest @@ -84,8 +85,8 @@ async def test_form_zeroconf_wrong_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.8", - addresses=["192.168.1.8"], + ip_address=ip_address("192.168.1.8"), + ip_addresses=[ip_address("192.168.1.8")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -104,8 +105,8 @@ async def test_form_zeroconf_link_local_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="169.254.103.61", - addresses=["169.254.103.61"], + ip_address=ip_address("169.254.103.61"), + ip_addresses=[ip_address("169.254.103.61")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -131,8 +132,8 @@ async def test_form_zeroconf_ipv4_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -152,8 +153,8 @@ async def test_form_zeroconf_non_ipv4_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -179,8 +180,8 @@ async def test_form_zeroconf_correct_oui(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, @@ -244,8 +245,8 @@ async def test_form_zeroconf_correct_oui_wrong_device( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="Doorstation - abc123._axis-video._tcp.local.", port=None, diff --git a/tests/components/elgato/test_config_flow.py b/tests/components/elgato/test_config_flow.py index 1b71a29632f789..bfae6fc9a17cce 100644 --- a/tests/components/elgato/test_config_flow.py +++ b/tests/components/elgato/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Elgato Key Light config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock from elgato import ElgatoConnectionError @@ -52,8 +53,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, @@ -110,8 +111,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -150,8 +151,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=9123, @@ -171,8 +172,8 @@ async def test_zeroconf_device_exists_abort( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.2", - addresses=["127.0.0.2"], + ip_address=ip_address("127.0.0.2"), + ip_addresses=[ip_address("127.0.0.2")], hostname="mock_hostname", name="mock_name", port=9123, @@ -200,8 +201,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="example.local.", name="mock_name", port=9123, diff --git a/tests/components/enphase_envoy/test_config_flow.py b/tests/components/enphase_envoy/test_config_flow.py index a4481f4ed519e3..25517e390caf38 100644 --- a/tests/components/enphase_envoy/test_config_flow.py +++ b/tests/components/enphase_envoy/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Enphase Envoy config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock from pyenphase import EnvoyAuthenticationError, EnvoyError @@ -175,8 +176,8 @@ async def test_zeroconf_pre_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -216,8 +217,8 @@ async def test_zeroconf_token_firmware( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -278,8 +279,8 @@ async def test_zeroconf_serial_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="4.4.4.4", - addresses=["4.4.4.4"], + ip_address=ip_address("4.4.4.4"), + ip_addresses=[ip_address("4.4.4.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -301,8 +302,8 @@ async def test_zeroconf_serial_already_exists_ignores_ipv6( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::b27c:63bb:cc85:4ea0", - addresses=["fd00::b27c:63bb:cc85:4ea0"], + ip_address=ip_address("fd00::b27c:63bb:cc85:4ea0"), + ip_addresses=[ip_address("fd00::b27c:63bb:cc85:4ea0")], hostname="mock_hostname", name="mock_name", port=None, @@ -325,8 +326,8 @@ async def test_zeroconf_host_already_exists( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/esphome/test_config_flow.py b/tests/components/esphome/test_config_flow.py index 63e181076235ba..01ba07852d6609 100644 --- a/tests/components/esphome/test_config_flow.py +++ b/tests/components/esphome/test_config_flow.py @@ -1,5 +1,6 @@ """Test config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -121,8 +122,8 @@ async def test_user_sets_unique_id( ) -> None: """Test that the user flow sets the unique id.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -198,8 +199,8 @@ async def test_user_causes_zeroconf_to_abort( ) -> None: """Test that the user flow sets the unique id and aborts the zeroconf flow.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -558,8 +559,8 @@ async def test_discovery_initiation( ) -> None: """Test discovery importing works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -590,8 +591,8 @@ async def test_discovery_no_mac( ) -> None: """Test discovery aborted if old ESPHome without mac in zeroconf.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -618,8 +619,8 @@ async def test_discovery_already_configured( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -639,8 +640,8 @@ async def test_discovery_duplicate_data( ) -> None: """Test discovery aborts if same mDNS packet arrives.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test.local.", name="mock_name", port=6053, @@ -674,8 +675,8 @@ async def test_discovery_updates_unique_id( entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1173,8 +1174,8 @@ async def test_zeroconf_encryption_key_via_dashboard( ) -> None: """Test encryption key retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1239,8 +1240,8 @@ async def test_zeroconf_encryption_key_via_dashboard_with_api_encryption_prop( ) -> None: """Test encryption key retrieved from dashboard with api_encryption property set.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, @@ -1305,8 +1306,8 @@ async def test_zeroconf_no_encryption_key_via_dashboard( ) -> None: """Test encryption key not retrieved from dashboard.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], hostname="test8266.local.", name="mock_name", port=6053, diff --git a/tests/components/forked_daapd/test_config_flow.py b/tests/components/forked_daapd/test_config_flow.py index fc02cdb4123194..080e47acc3e8f2 100644 --- a/tests/components/forked_daapd/test_config_flow.py +++ b/tests/components/forked_daapd/test_config_flow.py @@ -1,4 +1,5 @@ """The config flow tests for the forked_daapd media player platform.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch import pytest @@ -103,8 +104,8 @@ async def test_zeroconf_updates_title(hass: HomeAssistant, config_entry) -> None config_entry.add_to_hass(hass) assert len(hass.config_entries.async_entries(DOMAIN)) == 2 discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -138,8 +139,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: """Test that an invalid zeroconf entry doesn't work.""" # test with no discovery properties discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -153,8 +154,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with forked-daapd version < 27 discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -168,8 +169,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with verbose mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -183,8 +184,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: assert result["reason"] == "not_forked_daapd" # test with svn mtd-version from Firefly discovery_info = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=23, @@ -201,8 +202,8 @@ async def test_config_flow_zeroconf_invalid(hass: HomeAssistant) -> None: async def test_config_flow_zeroconf_valid(hass: HomeAssistant) -> None: """Test that a valid zeroconf entry works.""" discovery_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.1", - addresses=["192.168.1.1"], + ip_address=ip_address("192.168.1.1"), + ip_addresses=[ip_address("192.168.1.1")], hostname="mock_hostname", name="mock_name", port=23, diff --git a/tests/components/freebox/test_config_flow.py b/tests/components/freebox/test_config_flow.py index d8ea7107f23e9b..9d6f95b2559b49 100644 --- a/tests/components/freebox/test_config_flow.py +++ b/tests/components/freebox/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Freebox config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from freebox_api.exceptions import ( @@ -19,8 +20,8 @@ from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="192.168.0.254", - addresses=["192.168.0.254"], + ip_address=ip_address("192.168.0.254"), + ip_addresses=[ip_address("192.168.0.254")], port=80, hostname="Freebox-Server.local.", type="_fbx-api._tcp.local.", diff --git a/tests/components/gogogate2/test_config_flow.py b/tests/components/gogogate2/test_config_flow.py index 32d0f197bb5ee9..6de041257834bd 100644 --- a/tests/components/gogogate2/test_config_flow.py +++ b/tests/components/gogogate2/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the GogoGate2 component.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from ismartgate import GogoGate2Api, ISmartGateApi @@ -104,8 +105,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -132,8 +133,8 @@ async def test_form_homekit_unique_id_already_setup(hass: HomeAssistant) -> None DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -157,8 +158,8 @@ async def test_form_homekit_ip_address_already_setup(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -176,8 +177,8 @@ async def test_form_homekit_ip_address(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, @@ -259,8 +260,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/guardian/test_config_flow.py b/tests/components/guardian/test_config_flow.py index cb28ea22a379b3..3d0be516deaf74 100644 --- a/tests/components/guardian/test_config_flow.py +++ b/tests/components/guardian/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the Elexa Guardian config flow.""" +from ipaddress import ip_address from unittest.mock import patch from aioguardian.errors import GuardianError @@ -79,8 +80,8 @@ async def test_step_user(hass: HomeAssistant, config, setup_guardian) -> None: async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: """Test the zeroconf step.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", @@ -109,8 +110,8 @@ async def test_step_zeroconf(hass: HomeAssistant, setup_guardian) -> None: async def test_step_zeroconf_already_in_progress(hass: HomeAssistant) -> None: """Test the zeroconf step aborting because it's already in progress.""" zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], port=7777, hostname="GVC1-ABCD.local.", type="_api._udp.local.", diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index c989bc01ff2606..469bd8618d2038 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for homekit_controller config flow.""" import asyncio +from ipaddress import ip_address import unittest.mock from unittest.mock import AsyncMock, patch @@ -174,10 +175,10 @@ def get_device_discovery_info( ) -> zeroconf.ZeroconfServiceInfo: """Turn a aiohomekit format zeroconf entry into a homeassistant one.""" result = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname=device.description.name, name=device.description.name + "._hap._tcp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "md": device.description.model, diff --git a/tests/components/homewizard/test_config_flow.py b/tests/components/homewizard/test_config_flow.py index 7a1652549d79ba..7c6fb0bdb0d7f8 100644 --- a/tests/components/homewizard/test_config_flow.py +++ b/tests/components/homewizard/test_config_flow.py @@ -1,4 +1,5 @@ """Test the homewizard config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homewizard_energy.errors import DisabledError, RequestError, UnsupportedError @@ -58,8 +59,8 @@ async def test_discovery_flow_works( """Test discovery setup flow works.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -131,8 +132,8 @@ async def test_discovery_flow_during_onboarding( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -177,8 +178,8 @@ def mock_initialize(): DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="mock_type", @@ -229,8 +230,8 @@ async def test_discovery_disabled_api( """Test discovery detecting disabled api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -279,8 +280,8 @@ async def test_discovery_missing_data_in_service_info( """Test discovery detecting missing discovery info.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", @@ -310,8 +311,8 @@ async def test_discovery_invalid_api( """Test discovery detecting invalid_api.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.43.183", - addresses=["192.168.43.183"], + ip_address=ip_address("192.168.43.183"), + ip_addresses=[ip_address("192.168.43.183")], port=80, hostname="p1meter-ddeeff.local.", type="", diff --git a/tests/components/hue/test_config_flow.py b/tests/components/hue/test_config_flow.py index 6fa03e1de139ea..29b94b17da1ac7 100644 --- a/tests/components/hue/test_config_flow.py +++ b/tests/components/hue/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for Philips Hue config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import Mock, patch from aiohue.discovery import URL_NUPNP @@ -416,8 +417,8 @@ async def test_bridge_homekit( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -466,8 +467,8 @@ async def test_bridge_homekit_already_configured( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, @@ -568,8 +569,8 @@ async def test_bridge_zeroconf( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -604,8 +605,8 @@ async def test_bridge_zeroconf_already_exists( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.217", - addresses=["192.168.1.217"], + ip_address=ip_address("192.168.1.217"), + ip_addresses=[ip_address("192.168.1.217")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -629,8 +630,8 @@ async def test_bridge_zeroconf_ipv6(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="fd00::eeb5:faff:fe84:b17d", - addresses=["fd00::eeb5:faff:fe84:b17d"], + ip_address=ip_address("fd00::eeb5:faff:fe84:b17d"), + ip_addresses=[ip_address("fd00::eeb5:faff:fe84:b17d")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -677,8 +678,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="blah", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=443, hostname="Philips-hue.local", type="_hue._tcp.local.", @@ -698,8 +699,8 @@ async def test_bridge_connection_failed( const.DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("0.0.0.0"), + ip_addresses=[ip_address("0.0.0.0")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/hunterdouglas_powerview/test_config_flow.py b/tests/components/hunterdouglas_powerview/test_config_flow.py index 943de66baacd46..f39b4c1f68e568 100644 --- a/tests/components/hunterdouglas_powerview/test_config_flow.py +++ b/tests/components/hunterdouglas_powerview/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Logitech Harmony Hub config flow.""" import asyncio +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, patch @@ -12,9 +13,10 @@ from tests.common import MockConfigEntry, load_fixture +ZEROCONF_HOST = "1.2.3.4" HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._hap._tcp.local.", port=None, @@ -23,8 +25,8 @@ ) ZEROCONF_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name="Hunter Douglas Powerview Hub._powerview._tcp.local.", port=None, diff --git a/tests/components/ipp/__init__.py b/tests/components/ipp/__init__.py index f66630b2a6963e..ca374bd7e5ee2e 100644 --- a/tests/components/ipp/__init__.py +++ b/tests/components/ipp/__init__.py @@ -1,5 +1,7 @@ """Tests for the IPP integration.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.ipp.const import CONF_BASE_PATH from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL, CONF_VERIFY_SSL @@ -31,8 +33,8 @@ MOCK_ZEROCONF_IPP_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPP_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPP_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, @@ -41,8 +43,8 @@ MOCK_ZEROCONF_IPPS_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( type=IPPS_ZEROCONF_SERVICE_TYPE, name=f"{ZEROCONF_NAME}.{IPPS_ZEROCONF_SERVICE_TYPE}", - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname=ZEROCONF_HOSTNAME, port=ZEROCONF_PORT, properties={"rp": ZEROCONF_RP}, diff --git a/tests/components/ipp/test_config_flow.py b/tests/components/ipp/test_config_flow.py index 0daf8a0f7e0f33..5dd6c1af5bf018 100644 --- a/tests/components/ipp/test_config_flow.py +++ b/tests/components/ipp/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the IPP config flow.""" import dataclasses +from ipaddress import ip_address import json from unittest.mock import MagicMock, patch @@ -326,7 +327,9 @@ async def test_zeroconf_with_uuid_device_exists_abort_new_host( """Test we abort zeroconf flow if printer already configured.""" mock_config_entry.add_to_hass(hass) - discovery_info = dataclasses.replace(MOCK_ZEROCONF_IPP_SERVICE_INFO, host="1.2.3.9") + discovery_info = dataclasses.replace( + MOCK_ZEROCONF_IPP_SERVICE_INFO, ip_address=ip_address("1.2.3.9") + ) discovery_info.properties = { **MOCK_ZEROCONF_IPP_SERVICE_INFO.properties, "UUID": "cfe92100-67c4-11d4-a45f-f8d027761251", diff --git a/tests/components/kodi/util.py b/tests/components/kodi/util.py index 9fb215e2d8a963..2b9d819c244760 100644 --- a/tests/components/kodi/util.py +++ b/tests/components/kodi/util.py @@ -1,4 +1,6 @@ """Test the Kodi config flow.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.kodi.const import DEFAULT_SSL @@ -8,7 +10,6 @@ "ssl": DEFAULT_SSL, } - TEST_CREDENTIALS = {"username": "username", "password": "password"} @@ -16,8 +17,8 @@ UUID = "11111111-1111-1111-1111-111111111111" TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", @@ -27,8 +28,8 @@ TEST_DISCOVERY_WO_UUID = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=8080, hostname="hostname.local.", type="_xbmc-jsonrpc-h._tcp.local.", diff --git a/tests/components/lifx/test_config_flow.py b/tests/components/lifx/test_config_flow.py index 2adea42bed47e2..1b7da4f864a70b 100644 --- a/tests/components/lifx/test_config_flow.py +++ b/tests/components/lifx/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the lifx integration config flow.""" +from ipaddress import ip_address import socket from unittest.mock import patch @@ -388,8 +389,8 @@ async def test_discovered_by_discovery_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -443,8 +444,8 @@ async def test_discovered_by_dhcp_or_discovery( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, @@ -484,8 +485,8 @@ async def test_discovered_by_dhcp_or_discovery_failed_to_get_device( ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=LABEL, name=LABEL, port=None, diff --git a/tests/components/lookin/__init__.py b/tests/components/lookin/__init__.py index 11426f20e57898..bfbb5f66887443 100644 --- a/tests/components/lookin/__init__.py +++ b/tests/components/lookin/__init__.py @@ -1,6 +1,7 @@ """Tests for the lookin integration.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from aiolookin import Climate, Device, Remote @@ -18,8 +19,8 @@ ZC_NAME = f"LOOKin_{DEVICE_ID}" ZC_TYPE = "_lookin._tcp." ZEROCONF_DATA = ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname=f"{ZC_NAME.lower()}.local.", port=80, type=ZC_TYPE, diff --git a/tests/components/lookin/test_config_flow.py b/tests/components/lookin/test_config_flow.py index 1fd4479d100d8f..873e21a5cace5e 100644 --- a/tests/components/lookin/test_config_flow.py +++ b/tests/components/lookin/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations import dataclasses +from ipaddress import ip_address from unittest.mock import patch from aiolookin import NoUsableService @@ -135,7 +136,7 @@ async def test_discovered_zeroconf(hass: HomeAssistant) -> None: entry = hass.config_entries.async_entries(DOMAIN)[0] zc_data_new_ip = dataclasses.replace(ZEROCONF_DATA) - zc_data_new_ip.host = "127.0.0.2" + zc_data_new_ip.ip_address = ip_address("127.0.0.2") with _patch_get_info(), patch( f"{MODULE}.async_setup_entry", return_value=True diff --git a/tests/components/loqed/test_config_flow.py b/tests/components/loqed/test_config_flow.py index c9c577e7199995..617b6818a64c4b 100644 --- a/tests/components/loqed/test_config_flow.py +++ b/tests/components/loqed/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Loqed config flow.""" +from ipaddress import ip_address import json from unittest.mock import Mock, patch @@ -16,8 +17,8 @@ from tests.test_util.aiohttp import AiohttpClientMocker zeroconf_data = zeroconf.ZeroconfServiceInfo( - host="192.168.12.34", - addresses=["127.0.0.1"], + ip_address=ip_address("192.168.12.34"), + ip_addresses=[ip_address("192.168.12.34")], hostname="LOQED-ffeeddccbbaa.local", name="mock_name", port=9123, diff --git a/tests/components/lutron_caseta/test_config_flow.py b/tests/components/lutron_caseta/test_config_flow.py index 7f6a1b60511360..da26a55a4ef92e 100644 --- a/tests/components/lutron_caseta/test_config_flow.py +++ b/tests/components/lutron_caseta/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Lutron Caseta config flow.""" import asyncio +from ipaddress import ip_address from pathlib import Path import ssl from unittest.mock import AsyncMock, patch @@ -404,8 +405,8 @@ async def test_zeroconf_host_already_configured( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -432,8 +433,8 @@ async def test_zeroconf_lutron_id_already_configured(hass: HomeAssistant) -> Non DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, @@ -455,8 +456,8 @@ async def test_zeroconf_not_lutron_device(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="notlutron-abc.local.", name="mock_name", port=None, @@ -483,8 +484,8 @@ async def test_zeroconf(hass: HomeAssistant, source, tmp_path: Path) -> None: DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="LuTrOn-abc.local.", name="mock_name", port=None, diff --git a/tests/components/modern_forms/test_config_flow.py b/tests/components/modern_forms/test_config_flow.py index 540a8fef93de37..49bac6a5bb0b0c 100644 --- a/tests/components/modern_forms/test_config_flow.py +++ b/tests/components/modern_forms/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Modern Forms config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch import aiohttp @@ -65,8 +66,8 @@ async def test_full_zeroconf_flow_implementation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -134,8 +135,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -166,8 +167,8 @@ async def test_zeroconf_confirm_connection_error( CONF_NAME: "test", }, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.com.", name="mock_name", port=None, @@ -236,8 +237,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/nam/test_config_flow.py b/tests/components/nam/test_config_flow.py index 78a96e148cec13..a8f1245d9d63a3 100644 --- a/tests/components/nam/test_config_flow.py +++ b/tests/components/nam/test_config_flow.py @@ -1,5 +1,6 @@ """Define tests for the Nettigo Air Monitor config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from nettigo_air_monitor import ApiError, AuthFailedError, CannotGetMacError @@ -14,8 +15,8 @@ from tests.common import MockConfigEntry DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="10.10.2.3", - addresses=["10.10.2.3"], + ip_address=ip_address("10.10.2.3"), + ip_addresses=[ip_address("10.10.2.3")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/nanoleaf/test_config_flow.py b/tests/components/nanoleaf/test_config_flow.py index 9a7f4a2bc508fc..2fce4e55bbc9a9 100644 --- a/tests/components/nanoleaf/test_config_flow.py +++ b/tests/components/nanoleaf/test_config_flow.py @@ -1,6 +1,7 @@ """Test the Nanoleaf config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from aionanoleaf import InvalidToken, Unauthorized, Unavailable @@ -237,8 +238,8 @@ async def test_discovery_link_unavailable( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery_info}", port=None, @@ -372,8 +373,8 @@ async def test_import_discovery_integration( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=f"{TEST_NAME}.{type_in_discovery}", port=None, diff --git a/tests/components/netatmo/test_config_flow.py b/tests/components/netatmo/test_config_flow.py index a89fff13cdd4ba..56d319b1631726 100644 --- a/tests/components/netatmo/test_config_flow.py +++ b/tests/components/netatmo/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Netatmo config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyatmo.const import ALL_SCOPES @@ -44,8 +45,8 @@ async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: "netatmo", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="0.0.0.0", - addresses=["0.0.0.0"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/nut/test_config_flow.py b/tests/components/nut/test_config_flow.py index 8ce4916fc66865..46bc2bc2a64336 100644 --- a/tests/components/nut/test_config_flow.py +++ b/tests/components/nut/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Network UPS Tools (NUT) config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pynut2.nut2 import PyNUTError @@ -36,8 +37,8 @@ async def test_form_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], hostname="mock_hostname", name="mock_name", port=1234, diff --git a/tests/components/octoprint/test_config_flow.py b/tests/components/octoprint/test_config_flow.py index f2423f6da27bb7..e3cf45708fa97b 100644 --- a/tests/components/octoprint/test_config_flow.py +++ b/tests/components/octoprint/test_config_flow.py @@ -1,4 +1,5 @@ """Test the OctoPrint config flow.""" +from ipaddress import ip_address from unittest.mock import patch from pyoctoprintapi import ApiError, DiscoverySettings @@ -174,8 +175,8 @@ async def test_show_zerconf_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, @@ -496,8 +497,8 @@ async def test_duplicate_zerconf_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=80, diff --git a/tests/components/overkiz/test_config_flow.py b/tests/components/overkiz/test_config_flow.py index 89b0b7e84273b5..a9d950a3a6626a 100644 --- a/tests/components/overkiz/test_config_flow.py +++ b/tests/components/overkiz/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for Overkiz (by Somfy) config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import AsyncMock, Mock, patch from aiohttp import ClientError @@ -37,8 +38,8 @@ MOCK_GATEWAY2_RESPONSE = [Mock(id=TEST_GATEWAY_ID2)] FAKE_ZERO_CONF_INFO = ZeroconfServiceInfo( - host="192.168.0.51", - addresses=["192.168.0.51"], + ip_address=ip_address("192.168.0.51"), + ip_addresses=[ip_address("192.168.0.51")], port=443, hostname=f"gateway-{TEST_GATEWAY_ID}.local.", type="_kizbox._tcp.local.", diff --git a/tests/components/plugwise/test_config_flow.py b/tests/components/plugwise/test_config_flow.py index 6ca1e14a4cadea..438ab1b0870fcb 100644 --- a/tests/components/plugwise/test_config_flow.py +++ b/tests/components/plugwise/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Plugwise config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from plugwise.exceptions import ( @@ -36,8 +37,8 @@ TEST_USERNAME2 = "stretch" TEST_DISCOVERY = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], # The added `-2` is to simulate mDNS collision hostname=f"{TEST_HOSTNAME}-2.local.", name="mock_name", @@ -51,8 +52,8 @@ ) TEST_DISCOVERY2 = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, @@ -65,8 +66,8 @@ ) TEST_DISCOVERY_ANNA = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME}.local.", name="mock_name", port=DEFAULT_PORT, @@ -79,8 +80,8 @@ ) TEST_DISCOVERY_ADAM = ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname=f"{TEST_HOSTNAME2}.local.", name="mock_name", port=DEFAULT_PORT, diff --git a/tests/components/pure_energie/test_config_flow.py b/tests/components/pure_energie/test_config_flow.py index 2b00e975a8eef1..992ce8bbb2c8e5 100644 --- a/tests/components/pure_energie/test_config_flow.py +++ b/tests/components/pure_energie/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Pure Energie config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock from gridnet import GridNetConnectionError @@ -47,8 +48,8 @@ async def test_full_zeroconf_flow_implementationn( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -103,8 +104,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/rachio/test_config_flow.py b/tests/components/rachio/test_config_flow.py index 8d66725d20e12c..26083f51e634e0 100644 --- a/tests/components/rachio/test_config_flow.py +++ b/tests/components/rachio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Rachio config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -114,8 +115,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -139,8 +140,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -165,8 +166,8 @@ async def test_form_homekit_ignored(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/rainmachine/test_config_flow.py b/tests/components/rainmachine/test_config_flow.py index 0d95cbcce31242..5fa457bf771a01 100644 --- a/tests/components/rainmachine/test_config_flow.py +++ b/tests/components/rainmachine/test_config_flow.py @@ -1,4 +1,5 @@ """Define tests for the OpenUV config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -157,8 +158,8 @@ async def test_step_homekit_zeroconf_ip_already_exists( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -185,8 +186,8 @@ async def test_step_homekit_zeroconf_ip_change( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.2", - addresses=["192.168.1.2"], + ip_address=ip_address("192.168.1.2"), + ip_addresses=[ip_address("192.168.1.2")], hostname="mock_hostname", name="mock_name", port=None, @@ -214,8 +215,8 @@ async def test_step_homekit_zeroconf_new_controller_when_some_exist( DOMAIN, context={"source": source}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -264,8 +265,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, @@ -284,8 +285,8 @@ async def test_discovery_by_homekit_and_zeroconf_same_time( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.100", - addresses=["192.168.1.100"], + ip_address=ip_address("192.168.1.100"), + ip_addresses=[ip_address("192.168.1.100")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/roku/__init__.py b/tests/components/roku/__init__.py index 2ae0b308f9acc4..fc12bb9731dd90 100644 --- a/tests/components/roku/__init__.py +++ b/tests/components/roku/__init__.py @@ -1,4 +1,6 @@ """Tests for the Roku component.""" +from ipaddress import ip_address + from homeassistant.components import ssdp, zeroconf from homeassistant.components.ssdp import ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL @@ -23,8 +25,8 @@ HOMEKIT_HOST = "192.168.1.161" MOCK_HOMEKIT_DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host=HOMEKIT_HOST, - addresses=[HOMEKIT_HOST], + ip_address=ip_address(HOMEKIT_HOST), + ip_addresses=[ip_address(HOMEKIT_HOST)], hostname="mock_hostname", name="onn._hap._tcp.local.", port=None, diff --git a/tests/components/roomba/test_config_flow.py b/tests/components/roomba/test_config_flow.py index 0b39c34d3b8cbc..f62ca1a73b93b7 100644 --- a/tests/components/roomba/test_config_flow.py +++ b/tests/components/roomba/test_config_flow.py @@ -1,4 +1,5 @@ """Test the iRobot Roomba config flow.""" +from ipaddress import ip_address from unittest.mock import MagicMock, PropertyMock, patch import pytest @@ -36,25 +37,25 @@ ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="irobot-blid.local.", name="irobot-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ( config_entries.SOURCE_ZEROCONF, zeroconf.ZeroconfServiceInfo( - host=MOCK_IP, + ip_address=ip_address(MOCK_IP), + ip_addresses=[ip_address(MOCK_IP)], hostname="roomba-blid.local.", name="roomba-blid._amzn-alexa._tcp.local.", type="_amzn-alexa._tcp.local.", port=443, properties={}, - addresses=[MOCK_IP], ), ), ] diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index 3c4b982b000a75..a70a0042fcd310 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for Samsung TV config flow.""" +from ipaddress import ip_address from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -130,8 +131,8 @@ ) EXISTING_IP = "192.168.40.221" MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="fake_host", - addresses=["fake_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=1234, @@ -975,7 +976,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: ) assert result["type"] == "create_entry" assert result["title"] == "Living Room (82GXARRS)" - assert result["data"][CONF_HOST] == "fake_host" + assert result["data"][CONF_HOST] == "127.0.0.1" assert result["data"][CONF_NAME] == "Living Room" assert result["data"][CONF_MAC] == "aa:bb:ww:ii:ff:ii" assert result["data"][CONF_MANUFACTURER] == "Samsung" @@ -1273,7 +1274,9 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: """Test missing mac and unique id added.""" - entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) + entry = MockConfigEntry( + domain=DOMAIN, data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id=None + ) entry.add_to_hass(hass) result = await hass.config_entries.flow.async_init( @@ -1539,7 +1542,7 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry( domain=DOMAIN, - data=MOCK_OLD_ENTRY, + data={**MOCK_OLD_ENTRY, "host": "127.0.0.1"}, unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 7a29d7b1a42b8f..073847e03089ad 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations from dataclasses import replace +from ipaddress import ip_address from unittest.mock import AsyncMock, patch from aioshelly.exceptions import ( @@ -29,8 +30,8 @@ from tests.typing import WebSocketGenerator DISCOVERY_INFO = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-12345", port=None, @@ -38,8 +39,8 @@ type="mock_type", ) DISCOVERY_INFO_WITH_MAC = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="shelly1pm-AABBCCDDEEFF", port=None, @@ -651,7 +652,9 @@ async def test_zeroconf_with_wifi_ap_ip(hass: HomeAssistant) -> None: ): result = await hass.config_entries.flow.async_init( DOMAIN, - data=replace(DISCOVERY_INFO, host=config_flow.INTERNAL_WIFI_AP_IP), + data=replace( + DISCOVERY_INFO, ip_address=ip_address(config_flow.INTERNAL_WIFI_AP_IP) + ), context={"source": config_entries.SOURCE_ZEROCONF}, ) assert result["type"] == data_entry_flow.FlowResultType.ABORT diff --git a/tests/components/smappee/test_config_flow.py b/tests/components/smappee/test_config_flow.py index a6e8f8ae45c6df..f6f5ab66708040 100644 --- a/tests/components/smappee/test_config_flow.py +++ b/tests/components/smappee/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Smappee component config flow module.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch from homeassistant import data_entry_flow, setup @@ -59,8 +60,8 @@ async def test_show_zeroconf_connection_error_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -91,8 +92,8 @@ async def test_show_zeroconf_connection_error_form_next_generation( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", @@ -174,8 +175,8 @@ async def test_zeroconf_wrong_mdns(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="example.local.", type="_ssh._tcp.local.", @@ -285,8 +286,8 @@ async def test_zeroconf_device_exists_abort(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -335,8 +336,8 @@ async def test_zeroconf_abort_if_cloud_device_exists(hass: HomeAssistant) -> Non DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -357,8 +358,8 @@ async def test_zeroconf_confirm_abort_if_cloud_device_exists( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -480,8 +481,8 @@ async def test_full_zeroconf_flow(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee1006000212.local.", type="_ssh._tcp.local.", @@ -559,8 +560,8 @@ async def test_full_zeroconf_flow_next_generation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], port=22, hostname="Smappee5001000212.local.", type="_ssh._tcp.local.", diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index bab2b89009f575..cb912af1cf6799 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -1,5 +1,6 @@ """Configuration for Sonos tests.""" from copy import copy +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -69,8 +70,8 @@ def increment_variable(self, var_name): def zeroconf_payload(): """Return a default zeroconf payload.""" return zeroconf.ZeroconfServiceInfo( - host="192.168.4.2", - addresses=["192.168.4.2"], + ip_address=ip_address("192.168.4.2"), + ip_addresses=[ip_address("192.168.4.2")], hostname="Sonos-aaa", name="Sonos-aaa@Living Room._sonos._tcp.local.", port=None, diff --git a/tests/components/sonos/test_config_flow.py b/tests/components/sonos/test_config_flow.py index 270bdec4b52a84..2fd8ad110dfd08 100644 --- a/tests/components/sonos/test_config_flow.py +++ b/tests/components/sonos/test_config_flow.py @@ -1,6 +1,7 @@ """Test the sonos config flow.""" from __future__ import annotations +from ipaddress import ip_address from unittest.mock import MagicMock, patch from homeassistant import config_entries @@ -162,8 +163,8 @@ async def test_zeroconf_sonos_v1(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.107", - addresses=["192.168.1.107"], + ip_address=ip_address("192.168.1.107"), + ip_addresses=[ip_address("192.168.1.107")], port=1443, hostname="sonos5CAAFDE47AC8.local.", type="_sonos._tcp.local.", diff --git a/tests/components/soundtouch/test_config_flow.py b/tests/components/soundtouch/test_config_flow.py index 68f884ca0069c6..896202355ac1b8 100644 --- a/tests/components/soundtouch/test_config_flow.py +++ b/tests/components/soundtouch/test_config_flow.py @@ -1,4 +1,5 @@ """Test config flow.""" +from ipaddress import ip_address from unittest.mock import patch from requests import RequestException @@ -75,8 +76,8 @@ async def test_zeroconf_flow_create_entry( DOMAIN, context={CONF_SOURCE: SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host=DEVICE_1_IP, - addresses=[DEVICE_1_IP], + ip_address=ip_address(DEVICE_1_IP), + ip_addresses=[ip_address(DEVICE_1_IP)], port=8090, hostname="Bose-SM2-060000000001.local.", type="_soundtouch._tcp.local.", diff --git a/tests/components/spotify/test_config_flow.py b/tests/components/spotify/test_config_flow.py index 46d9741684ae61..7940964d68f94f 100644 --- a/tests/components/spotify/test_config_flow.py +++ b/tests/components/spotify/test_config_flow.py @@ -1,5 +1,6 @@ """Tests for the Spotify config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -22,8 +23,8 @@ from tests.typing import ClientSessionGenerator BLANK_ZEROCONF_INFO = zeroconf.ZeroconfServiceInfo( - host="1.2.3.4", - addresses=["1.2.3.4"], + ip_address=ip_address("1.2.3.4"), + ip_addresses=[ip_address("1.2.3.4")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index ef4dee7c597f52..4d4ba583169871 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the Synology DSM config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, Mock, patch import pytest @@ -666,8 +667,8 @@ async def test_discovered_via_zeroconf(hass: HomeAssistant, service: MagicMock) DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", @@ -714,8 +715,8 @@ async def test_discovered_via_zeroconf_missing_mac( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.5", - addresses=["192.168.1.5"], + ip_address=ip_address("192.168.1.5"), + ip_addresses=[ip_address("192.168.1.5")], port=5000, hostname="mydsm.local.", type="_http._tcp.local.", diff --git a/tests/components/system_bridge/test_config_flow.py b/tests/components/system_bridge/test_config_flow.py index d01ed9a3ff8f44..56afc87c3bba39 100644 --- a/tests/components/system_bridge/test_config_flow.py +++ b/tests/components/system_bridge/test_config_flow.py @@ -1,5 +1,6 @@ """Test the System Bridge config flow.""" import asyncio +from ipaddress import ip_address from unittest.mock import patch from systembridgeconnector.const import MODEL_SYSTEM, TYPE_DATA_UPDATE @@ -37,8 +38,8 @@ } FIXTURE_ZEROCONF = zeroconf.ZeroconfServiceInfo( - host="test-bridge", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", @@ -55,8 +56,8 @@ ) FIXTURE_ZEROCONF_BAD = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], port=9170, hostname="test-bridge.local.", type="_system-bridge._tcp.local.", diff --git a/tests/components/tado/test_config_flow.py b/tests/components/tado/test_config_flow.py index dcbb33b587ef34..c4a39914e53a23 100644 --- a/tests/components/tado/test_config_flow.py +++ b/tests/components/tado/test_config_flow.py @@ -1,5 +1,6 @@ """Test the Tado config flow.""" from http import HTTPStatus +from ipaddress import ip_address from unittest.mock import MagicMock, patch import pytest @@ -222,8 +223,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, @@ -249,8 +250,8 @@ async def test_form_homekit(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="mock_host", - addresses=["mock_host"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/thread/test_config_flow.py b/tests/components/thread/test_config_flow.py index 7ff096795ca82c..51ebe3b5976370 100644 --- a/tests/components/thread/test_config_flow.py +++ b/tests/components/thread/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Thread config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant.components import thread, zeroconf @@ -6,10 +7,10 @@ from homeassistant.data_entry_flow import FlowResultType TEST_ZEROCONF_RECORD = zeroconf.ZeroconfServiceInfo( - host="127.0.0.1", + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="HomeAssistant OpenThreadBorderRouter #0BBF", name="HomeAssistant OpenThreadBorderRouter #0BBF._meshcop._udp.local.", - addresses=["127.0.0.1"], port=8080, properties={ "rv": "1", diff --git a/tests/components/tradfri/test_config_flow.py b/tests/components/tradfri/test_config_flow.py index 9eff7335820457..3f5c71645c8e6f 100644 --- a/tests/components/tradfri/test_config_flow.py +++ b/tests/components/tradfri/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Tradfri config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, patch import pytest @@ -113,8 +114,8 @@ async def test_discovery_connection( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -148,8 +149,8 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="new-host", - addresses=["new-host"], + ip_address=ip_address("123.123.123.124"), + ip_addresses=[ip_address("123.123.123.124")], hostname="mock_hostname", name="mock_name", port=None, @@ -161,7 +162,7 @@ async def test_discovery_duplicate_aborted(hass: HomeAssistant) -> None: assert flow["type"] == data_entry_flow.FlowResultType.ABORT assert flow["reason"] == "already_configured" - assert entry.data["host"] == "new-host" + assert entry.data["host"] == "123.123.123.124" async def test_duplicate_discovery( @@ -172,8 +173,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -188,8 +189,8 @@ async def test_duplicate_discovery( "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="123.123.123.123", - addresses=["123.123.123.123"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, @@ -205,7 +206,7 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: """Test a duplicate discovery host aborts and updates existing entry.""" entry = MockConfigEntry( domain="tradfri", - data={"host": "some-host"}, + data={"host": "123.123.123.123"}, ) entry.add_to_hass(hass) @@ -213,8 +214,8 @@ async def test_discovery_updates_unique_id(hass: HomeAssistant) -> None: "tradfri", context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host="some-host", - addresses=["some-host"], + ip_address=ip_address("123.123.123.123"), + ip_addresses=[ip_address("123.123.123.123")], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/vizio/const.py b/tests/components/vizio/const.py index 119443962fced3..849c13d4396a56 100644 --- a/tests/components/vizio/const.py +++ b/tests/components/vizio/const.py @@ -1,4 +1,6 @@ """Constants for the Vizio integration tests.""" +from ipaddress import ip_address + from homeassistant.components import zeroconf from homeassistant.components.media_player import ( DOMAIN as MP_DOMAIN, @@ -197,8 +199,8 @@ def __init__(self, auth_token: str) -> None: ZEROCONF_PORT = HOST.split(":")[1] MOCK_ZEROCONF_SERVICE_INFO = zeroconf.ZeroconfServiceInfo( - host=ZEROCONF_HOST, - addresses=[ZEROCONF_HOST], + ip_address=ip_address(ZEROCONF_HOST), + ip_addresses=[ip_address(ZEROCONF_HOST)], hostname="mock_hostname", name=ZEROCONF_NAME, port=ZEROCONF_PORT, diff --git a/tests/components/vizio/test_config_flow.py b/tests/components/vizio/test_config_flow.py index 4c47a0c56408a6..578d79fcba0a57 100644 --- a/tests/components/vizio/test_config_flow.py +++ b/tests/components/vizio/test_config_flow.py @@ -801,8 +801,9 @@ async def test_zeroconf_flow_with_port_in_host( entry.add_to_hass(hass) # Try rediscovering same device, this time with port already in host + # This test needs to be refactored as the port is never in the host + # field of the zeroconf service info discovery_info = dataclasses.replace(MOCK_ZEROCONF_SERVICE_INFO) - discovery_info.host = f"{discovery_info.host}:{discovery_info.port}" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=discovery_info ) diff --git a/tests/components/volumio/test_config_flow.py b/tests/components/volumio/test_config_flow.py index 5d734d1b2d55b0..841b558eba38f9 100644 --- a/tests/components/volumio/test_config_flow.py +++ b/tests/components/volumio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Volumio config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -19,8 +20,8 @@ TEST_DISCOVERY = zeroconf.ZeroconfServiceInfo( - host="1.1.1.1", - addresses=["1.1.1.1"], + ip_address=ip_address("1.1.1.1"), + ip_addresses=[ip_address("1.1.1.1")], hostname="mock_hostname", name="mock_name", port=3000, diff --git a/tests/components/wled/test_config_flow.py b/tests/components/wled/test_config_flow.py index 9f99bd58615303..de01510adb370b 100644 --- a/tests/components/wled/test_config_flow.py +++ b/tests/components/wled/test_config_flow.py @@ -1,4 +1,5 @@ """Tests for the WLED config flow.""" +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock import pytest @@ -44,8 +45,8 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -88,8 +89,8 @@ async def test_zeroconf_during_onboarding( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -133,8 +134,8 @@ async def test_zeroconf_connection_error( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -193,8 +194,8 @@ async def test_zeroconf_without_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -218,8 +219,8 @@ async def test_zeroconf_with_mac_device_exists_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, @@ -243,8 +244,8 @@ async def test_zeroconf_with_cct_channel_abort( DOMAIN, context={"source": SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host="192.168.1.123", - addresses=["192.168.1.123"], + ip_address=ip_address("192.168.1.123"), + ip_addresses=[ip_address("192.168.1.123")], hostname="example.local.", name="mock_name", port=None, diff --git a/tests/components/xiaomi_aqara/test_config_flow.py b/tests/components/xiaomi_aqara/test_config_flow.py index 2f049a8662030a..d15a442a840220 100644 --- a/tests/components/xiaomi_aqara/test_config_flow.py +++ b/tests/components/xiaomi_aqara/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Aqara config flow.""" +from ipaddress import ip_address from socket import gaierror from unittest.mock import Mock, patch @@ -403,8 +404,8 @@ async def test_zeroconf_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -450,8 +451,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -470,8 +471,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-aqara-gateway", port=None, diff --git a/tests/components/xiaomi_miio/test_config_flow.py b/tests/components/xiaomi_miio/test_config_flow.py index 848bb7c8d9f624..a436908b44ff96 100644 --- a/tests/components/xiaomi_miio/test_config_flow.py +++ b/tests/components/xiaomi_miio/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Xiaomi Miio config flow.""" +from ipaddress import ip_address from unittest.mock import Mock, patch from construct.core import ChecksumError @@ -426,8 +427,8 @@ async def test_zeroconf_gateway_success(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -469,8 +470,8 @@ async def test_zeroconf_unknown_device(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name="not-a-xiaomi-miio-device", port=None, @@ -489,8 +490,8 @@ async def test_zeroconf_no_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=None, - addresses=[], + ip_address=None, + ip_addresses=[], hostname="mock_hostname", name=None, port=None, @@ -509,8 +510,8 @@ async def test_zeroconf_missing_data(hass: HomeAssistant) -> None: const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=TEST_ZEROCONF_NAME, port=None, @@ -791,8 +792,8 @@ async def zeroconf_device_success(hass, zeroconf_name_to_test, model_to_test): const.DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=zeroconf.ZeroconfServiceInfo( - host=TEST_HOST, - addresses=[TEST_HOST], + ip_address=ip_address(TEST_HOST), + ip_addresses=[ip_address(TEST_HOST)], hostname="mock_hostname", name=zeroconf_name_to_test, port=None, diff --git a/tests/components/yeelight/__init__.py b/tests/components/yeelight/__init__.py index d60ead707fb5d3..c7d279220f8766 100644 --- a/tests/components/yeelight/__init__.py +++ b/tests/components/yeelight/__init__.py @@ -1,5 +1,6 @@ """Tests for the Yeelight integration.""" from datetime import timedelta +from ipaddress import ip_address from unittest.mock import AsyncMock, MagicMock, patch from async_upnp_client.search import SsdpSearchListener @@ -42,8 +43,8 @@ ID_DECIMAL = f"{int(ID, 16):08d}" ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], port=54321, hostname=f"yeelink-light-strip1_miio{ID_DECIMAL}.local.", type="_miio._udp.local.", diff --git a/tests/components/yeelight/test_config_flow.py b/tests/components/yeelight/test_config_flow.py index 8f46407aff6845..0bd5b5f59d0bd2 100644 --- a/tests/components/yeelight/test_config_flow.py +++ b/tests/components/yeelight/test_config_flow.py @@ -1,4 +1,5 @@ """Test the Yeelight config flow.""" +from ipaddress import ip_address from unittest.mock import patch import pytest @@ -465,8 +466,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, data=zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -535,8 +536,8 @@ async def test_discovered_by_homekit_and_dhcp(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -603,8 +604,8 @@ async def test_discovered_by_dhcp_or_homekit(hass: HomeAssistant, source, data) ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, @@ -827,8 +828,8 @@ async def test_discovery_adds_missing_ip_id_only(hass: HomeAssistant) -> None: ( config_entries.SOURCE_HOMEKIT, zeroconf.ZeroconfServiceInfo( - host=IP_ADDRESS, - addresses=[IP_ADDRESS], + ip_address=ip_address(IP_ADDRESS), + ip_addresses=[ip_address(IP_ADDRESS)], hostname="mock_hostname", name="mock_name", port=None, diff --git a/tests/components/zeroconf/test_init.py b/tests/components/zeroconf/test_init.py index a6ff257d78cea5..54406bb1b4d6a0 100644 --- a/tests/components/zeroconf/test_init.py +++ b/tests/components/zeroconf/test_init.py @@ -859,6 +859,7 @@ async def test_info_from_service_with_link_local_address_first( service_info.addresses = ["169.254.12.3", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["169.254.12.3", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_first( @@ -870,6 +871,7 @@ async def test_info_from_service_with_unspecified_address_first( service_info.addresses = ["0.0.0.0", "192.168.66.12"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["0.0.0.0", "192.168.66.12"] async def test_info_from_service_with_unspecified_address_only( @@ -892,6 +894,7 @@ async def test_info_from_service_with_link_local_address_second( service_info.addresses = ["192.168.66.12", "169.254.12.3"] info = zeroconf.info_from_service(service_info) assert info.host == "192.168.66.12" + assert info.addresses == ["192.168.66.12", "169.254.12.3"] async def test_info_from_service_with_link_local_address_only( diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 981ca2aca380fa..9ec8048ea0339c 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1,6 +1,7 @@ """Tests for ZHA config flow.""" import copy from datetime import timedelta +from ipaddress import ip_address import json from unittest.mock import AsyncMock, MagicMock, PropertyMock, create_autospec, patch import uuid @@ -142,8 +143,8 @@ def com_port(device="/dev/ttyUSB1234"): async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -192,8 +193,8 @@ async def test_zeroconf_discovery_znp(hass: HomeAssistant) -> None: async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> None: """Test zeroconf flow -- zigate radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_zigate-zigbee-gateway._tcp.local.", name="any", port=1234, @@ -247,8 +248,8 @@ async def test_zigate_via_zeroconf(setup_entry_mock, hass: HomeAssistant) -> Non async def test_efr32_via_zeroconf(hass: HomeAssistant) -> None: """Test zeroconf flow -- efr32 radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="efr32._esphomelib._tcp.local.", name="efr32", port=1234, @@ -310,8 +311,8 @@ async def test_discovery_via_zeroconf_ip_change(hass: HomeAssistant) -> None: entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -343,8 +344,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> entry.add_to_hass(hass) service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.22", - addresses=["192.168.1.22"], + ip_address=ip_address("192.168.1.22"), + ip_addresses=[ip_address("192.168.1.22")], hostname="tube_zb_gw_cc2652p2_poe.local.", name="mock_name", port=6053, @@ -365,8 +366,8 @@ async def test_discovery_via_zeroconf_ip_change_ignored(hass: HomeAssistant) -> async def test_discovery_confirm_final_abort_if_entries(hass: HomeAssistant) -> None: """Test discovery aborts if ZHA was set up after the confirmation dialog is shown.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="tube._tube_zb_gw._tcp.local.", name="tube", port=6053, @@ -698,8 +699,8 @@ async def test_discovery_via_usb_zha_ignored_updates(hass: HomeAssistant) -> Non async def test_discovery_already_setup(hass: HomeAssistant) -> None: """Test zeroconf flow -- radio detected.""" service_info = zeroconf.ZeroconfServiceInfo( - host="192.168.1.200", - addresses=["192.168.1.200"], + ip_address=ip_address("192.168.1.200"), + ip_addresses=[ip_address("192.168.1.200")], hostname="_tube_zb_gw._tcp.local.", name="mock_name", port=6053, diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 73dd82d5f4b176..a051f398d8c9e2 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -2,6 +2,7 @@ import asyncio from collections.abc import Generator from copy import copy +from ipaddress import ip_address from unittest.mock import DEFAULT, MagicMock, call, patch import aiohttp @@ -2672,8 +2673,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_ZEROCONF}, data=ZeroconfServiceInfo( - host="localhost", - addresses=["127.0.0.1"], + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], hostname="mock_hostname", name="mock_name", port=3000, @@ -2697,7 +2698,7 @@ async def test_zeroconf(hass: HomeAssistant) -> None: assert result["type"] == "create_entry" assert result["title"] == TITLE assert result["data"] == { - "url": "ws://localhost:3000", + "url": "ws://127.0.0.1:3000", "usb_path": None, "s0_legacy_key": None, "s2_access_control_key": None, diff --git a/tests/components/zwave_me/test_config_flow.py b/tests/components/zwave_me/test_config_flow.py index 7d1919a8698894..145cecd58c8491 100644 --- a/tests/components/zwave_me/test_config_flow.py +++ b/tests/components/zwave_me/test_config_flow.py @@ -1,4 +1,5 @@ """Test the zwave_me config flow.""" +from ipaddress import ip_address from unittest.mock import patch from homeassistant import config_entries @@ -10,10 +11,10 @@ from tests.common import MockConfigEntry MOCK_ZEROCONF_DATA = zeroconf.ZeroconfServiceInfo( - host="ws://192.168.1.14", + ip_address=ip_address("192.168.1.14"), + ip_addresses=[ip_address("192.168.1.14")], hostname="mock_hostname", name="mock_name", - addresses=["192.168.1.14"], port=1234, properties={ "deviceid": "aa:bb:cc:dd:ee:ff", From c099ec19f22a19205f1268e3bd3e8b8887b1965e Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 19 Sep 2023 18:30:18 +0000 Subject: [PATCH 603/640] Add missing translations for Shelly event type states (#100608) Add missing translations for event type --- homeassistant/components/shelly/event.py | 1 + homeassistant/components/shelly/strings.json | 16 ++++++++++++++++ 2 files changed, 17 insertions(+) diff --git a/homeassistant/components/shelly/event.py b/homeassistant/components/shelly/event.py index e37b4cdcdacb0c..2abedf3cf9a7f1 100644 --- a/homeassistant/components/shelly/event.py +++ b/homeassistant/components/shelly/event.py @@ -37,6 +37,7 @@ class ShellyEventDescription(EventEntityDescription): RPC_EVENT: Final = ShellyEventDescription( key="input", + translation_key="input", device_class=EventDeviceClass.BUTTON, event_types=list(RPC_INPUTS_EVENTS_TYPES), removal_condition=lambda config, status, key: not is_rpc_momentary_input( diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index dcdfa6d7987bb0..d2e72ee81da398 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -98,6 +98,22 @@ } } }, + "event": { + "input": { + "state_attributes": { + "event_type": { + "state": { + "btn_down": "Button down", + "btn_up": "Button up", + "double_push": "Double push", + "long_push": "Long push", + "single_push": "Single push", + "triple_push": "Triple push" + } + } + } + } + }, "sensor": { "operation": { "state": { From f1a70189acc4e01ac4e91b23782825f978f14579 Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Tue, 19 Sep 2023 22:14:21 +0200 Subject: [PATCH 604/640] Clean-up Minecraft Server tests (#100615) Remove patching of getmac, fix typo --- tests/components/minecraft_server/test_config_flow.py | 4 ++-- tests/components/minecraft_server/test_init.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/components/minecraft_server/test_config_flow.py b/tests/components/minecraft_server/test_config_flow.py index c4d8c72e32de4d..463a78b468081a 100644 --- a/tests/components/minecraft_server/test_config_flow.py +++ b/tests/components/minecraft_server/test_config_flow.py @@ -149,7 +149,7 @@ async def test_connection_succeeded_with_host(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv4 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( @@ -168,7 +168,7 @@ async def test_connection_succeeded_with_ip4(hass: HomeAssistant) -> None: async def test_connection_succeeded_with_ip6(hass: HomeAssistant) -> None: """Test config entry in case of a successful connection with an IPv6 address.""" - with patch("getmac.get_mac_address", return_value="01:23:45:67:89:ab"), patch( + with patch( "aiodns.DNSResolver.query", side_effect=aiodns.error.DNSError, ), patch( diff --git a/tests/components/minecraft_server/test_init.py b/tests/components/minecraft_server/test_init.py index 5bdce5ed9b7417..77b6901a0a2ce5 100644 --- a/tests/components/minecraft_server/test_init.py +++ b/tests/components/minecraft_server/test_init.py @@ -33,7 +33,7 @@ async def test_entry_migration_v1_to_v2(hass: HomeAssistant) -> None: - """Test entry migratiion from version 1 to 2.""" + """Test entry migration from version 1 to 2.""" # Create mock config entry. config_entry_v1 = MockConfigEntry( From 1d5905b591e612c25615ba6cb7510c3964a96bee Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 01:08:32 +0200 Subject: [PATCH 605/640] Use is for UNDEFINED check in async_update_entry (#100599) --- homeassistant/config_entries.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 046f403642eca3..f4e61bfffbd6b5 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1348,7 +1348,7 @@ def async_update_entry( ("pref_disable_new_entities", pref_disable_new_entities), ("pref_disable_polling", pref_disable_polling), ): - if value == UNDEFINED or getattr(entry, attr) == value: + if value is UNDEFINED or getattr(entry, attr) == value: continue setattr(entry, attr, value) From 6c095a963dd1b914f5b0aca0d7a7a7dfbc3904d7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 01:08:58 +0200 Subject: [PATCH 606/640] Switch config flows use newer zeroconf methods to check IP Addresses (#100568) --- homeassistant/components/apple_tv/config_flow.py | 5 ++--- homeassistant/components/baf/config_flow.py | 5 ++--- homeassistant/components/hue/config_flow.py | 3 +-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/apple_tv/config_flow.py b/homeassistant/components/apple_tv/config_flow.py index 8a2130faca076e..6a85ea1d1a817c 100644 --- a/homeassistant/components/apple_tv/config_flow.py +++ b/homeassistant/components/apple_tv/config_flow.py @@ -26,7 +26,6 @@ SchemaFlowFormStep, SchemaOptionsFlowHandler, ) -from homeassistant.util.network import is_ipv6_address from .const import CONF_CREDENTIALS, CONF_IDENTIFIERS, CONF_START_OFF, DOMAIN @@ -184,9 +183,9 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle device found via zeroconf.""" - host = discovery_info.host - if is_ipv6_address(host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="ipv6_not_supported") + host = discovery_info.host self._async_abort_entries_match({CONF_ADDRESS: host}) service_type = discovery_info.type[:-1] # Remove leading . name = discovery_info.name.replace(f".{service_type}.", "") diff --git a/homeassistant/components/baf/config_flow.py b/homeassistant/components/baf/config_flow.py index bbae391453386b..9edb23abcf84b6 100644 --- a/homeassistant/components/baf/config_flow.py +++ b/homeassistant/components/baf/config_flow.py @@ -14,7 +14,6 @@ from homeassistant.components import zeroconf from homeassistant.const import CONF_IP_ADDRESS from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv6_address from .const import DOMAIN, RUN_TIMEOUT from .models import BAFDiscovery @@ -49,10 +48,10 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle zeroconf discovery.""" + if discovery_info.ip_address.version == 6: + return self.async_abort(reason="ipv6_not_supported") properties = discovery_info.properties ip_address = discovery_info.host - if is_ipv6_address(ip_address): - return self.async_abort(reason="ipv6_not_supported") uuid = properties["uuid"] model = properties["model"] name = properties["name"] diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index 9c8dda94c942bf..0957329abb0b83 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -22,7 +22,6 @@ config_validation as cv, device_registry as dr, ) -from homeassistant.util.network import is_ipv6_address from .const import ( CONF_ALLOW_HUE_GROUPS, @@ -219,7 +218,7 @@ async def async_step_zeroconf( host is already configured and delegate to the import step if not. """ # Ignore if host is IPv6 - if is_ipv6_address(discovery_info.host): + if discovery_info.ip_address.version == 6: return self.async_abort(reason="invalid_host") # abort if we already have exactly this bridge id/host From bd9bab000e1e4dbe651290ebba1600f6949e91a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 20 Sep 2023 01:44:35 +0100 Subject: [PATCH 607/640] Add integration for IKEA Idasen Desk (#99173) Co-authored-by: J. Nick Koston --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/brands/ikea.json | 2 +- .../components/idasen_desk/__init__.py | 94 +++++++ .../components/idasen_desk/config_flow.py | 115 +++++++++ homeassistant/components/idasen_desk/const.py | 6 + homeassistant/components/idasen_desk/cover.py | 101 ++++++++ .../components/idasen_desk/manifest.json | 15 ++ .../components/idasen_desk/strings.json | 22 ++ homeassistant/generated/bluetooth.py | 4 + 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/idasen_desk/__init__.py | 51 ++++ tests/components/idasen_desk/conftest.py | 49 ++++ .../idasen_desk/test_config_flow.py | 230 ++++++++++++++++++ tests/components/idasen_desk/test_cover.py | 82 +++++++ tests/components/idasen_desk/test_init.py | 55 +++++ 20 files changed, 851 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/idasen_desk/__init__.py create mode 100644 homeassistant/components/idasen_desk/config_flow.py create mode 100644 homeassistant/components/idasen_desk/const.py create mode 100644 homeassistant/components/idasen_desk/cover.py create mode 100644 homeassistant/components/idasen_desk/manifest.json create mode 100644 homeassistant/components/idasen_desk/strings.json create mode 100644 tests/components/idasen_desk/__init__.py create mode 100644 tests/components/idasen_desk/conftest.py create mode 100644 tests/components/idasen_desk/test_config_flow.py create mode 100644 tests/components/idasen_desk/test_cover.py create mode 100644 tests/components/idasen_desk/test_init.py diff --git a/.strict-typing b/.strict-typing index 56c7bf248e18c4..97af46884c4f8d 100644 --- a/.strict-typing +++ b/.strict-typing @@ -180,6 +180,7 @@ homeassistant.components.huawei_lte.* homeassistant.components.hydrawise.* homeassistant.components.hyperion.* homeassistant.components.ibeacon.* +homeassistant.components.idasen_desk.* homeassistant.components.image.* homeassistant.components.image_processing.* homeassistant.components.image_upload.* diff --git a/CODEOWNERS b/CODEOWNERS index b3d2889b1088fa..fe6aba2e5bb1c3 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -569,6 +569,8 @@ build.json @home-assistant/supervisor /tests/components/ibeacon/ @bdraco /homeassistant/components/icloud/ @Quentame @nzapponi /tests/components/icloud/ @Quentame @nzapponi +/homeassistant/components/idasen_desk/ @abmantis +/tests/components/idasen_desk/ @abmantis /homeassistant/components/ign_sismologia/ @exxamalte /tests/components/ign_sismologia/ @exxamalte /homeassistant/components/image/ @home-assistant/core diff --git a/homeassistant/brands/ikea.json b/homeassistant/brands/ikea.json index 702a59ad4d1b08..dee69001adda5d 100644 --- a/homeassistant/brands/ikea.json +++ b/homeassistant/brands/ikea.json @@ -1,5 +1,5 @@ { "domain": "ikea", "name": "IKEA", - "integrations": ["symfonisk", "tradfri"] + "integrations": ["symfonisk", "tradfri", "idasen_desk"] } diff --git a/homeassistant/components/idasen_desk/__init__.py b/homeassistant/components/idasen_desk/__init__.py new file mode 100644 index 00000000000000..5fd23ba47e02ca --- /dev/null +++ b/homeassistant/components/idasen_desk/__init__.py @@ -0,0 +1,94 @@ +"""The IKEA Idasen Desk integration.""" +from __future__ import annotations + +import logging + +from attr import dataclass +from bleak import BleakError +from idasen_ha import Desk + +from homeassistant.components import bluetooth +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_NAME, + CONF_ADDRESS, + EVENT_HOMEASSISTANT_STOP, + Platform, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DOMAIN + +PLATFORMS: list[Platform] = [Platform.COVER] + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class DeskData: + """Data for the Idasen Desk integration.""" + + desk: Desk + address: str + device_info: DeviceInfo + coordinator: DataUpdateCoordinator + + +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, + ) + + 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 + ) + + ble_device = bluetooth.async_ble_device_from_address( + hass, address, connectable=True + ) + try: + await desk.connect(ble_device) + except (TimeoutError, BleakError) as ex: + raise ConfigEntryNotReady(f"Unable to connect to desk {address}") from ex + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + + async def _async_stop(event: Event) -> None: + """Close the connection.""" + await desk.disconnect() + + entry.async_on_unload( + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _async_stop) + ) + return True + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle options update.""" + data: DeskData = hass.data[DOMAIN][entry.entry_id] + if entry.title != data.device_info[ATTR_NAME]: + await hass.config_entries.async_reload(entry.entry_id) + + +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() + + return unload_ok diff --git a/homeassistant/components/idasen_desk/config_flow.py b/homeassistant/components/idasen_desk/config_flow.py new file mode 100644 index 00000000000000..f56446396d2c55 --- /dev/null +++ b/homeassistant/components/idasen_desk/config_flow.py @@ -0,0 +1,115 @@ +"""Config flow for Idasen Desk integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from bleak import BleakError +from bluetooth_data_tools import human_readable_name +from idasen_ha import Desk +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.components.bluetooth import ( + BluetoothServiceInfoBleak, + async_discovered_service_info, +) +from homeassistant.const import CONF_ADDRESS +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, EXPECTED_SERVICE_UUID + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Idasen Desk integration.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery_info: BluetoothServiceInfoBleak | None = None + self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {} + + 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() + self._discovery_info = discovery_info + self.context["title_placeholders"] = { + "name": human_readable_name( + None, discovery_info.name, discovery_info.address + ) + } + return await self.async_step_user() + + 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] + discovery_info = self._discovered_devices[address] + local_name = discovery_info.name + await self.async_set_unique_id( + discovery_info.address, raise_on_progress=False + ) + self._abort_if_unique_id_configured() + + desk = Desk(None) + try: + await desk.connect(discovery_info.device, monitor_height=False) + except TimeoutError as err: + _LOGGER.exception("TimeoutError", exc_info=err) + errors["base"] = "cannot_connect" + except BleakError as err: + _LOGGER.exception("BleakError", exc_info=err) + errors["base"] = "cannot_connect" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error") + errors["base"] = "unknown" + else: + await desk.disconnect() + return self.async_create_entry( + title=local_name, + data={ + CONF_ADDRESS: discovery_info.address, + }, + ) + + if discovery := self._discovery_info: + self._discovered_devices[discovery.address] = discovery + else: + 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 EXPECTED_SERVICE_UUID not in discovery.service_uuids + ): + 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, + ) diff --git a/homeassistant/components/idasen_desk/const.py b/homeassistant/components/idasen_desk/const.py new file mode 100644 index 00000000000000..0d37d77307b3ab --- /dev/null +++ b/homeassistant/components/idasen_desk/const.py @@ -0,0 +1,6 @@ +"""Constants for the Idasen Desk integration.""" + + +DOMAIN = "idasen_desk" + +EXPECTED_SERVICE_UUID = "99fa0001-338a-1024-8a49-009c0215f78a" diff --git a/homeassistant/components/idasen_desk/cover.py b/homeassistant/components/idasen_desk/cover.py new file mode 100644 index 00000000000000..c1d1bb48fd8601 --- /dev/null +++ b/homeassistant/components/idasen_desk/cover.py @@ -0,0 +1,101 @@ +"""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, + CoverEntity, + CoverEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +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 . import DeskData +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """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)] + ) + + +class IdasenDeskCover(CoordinatorEntity, CoverEntity): + """Representation of Idasen Desk device.""" + + _attr_device_class = CoverDeviceClass.DAMPER + _attr_icon = "mdi:desk" + _attr_supported_features = ( + CoverEntityFeature.OPEN + | CoverEntityFeature.CLOSE + | CoverEntityFeature.STOP + | CoverEntityFeature.SET_POSITION + ) + + def __init__( + self, + desk: Desk, + address: str, + device_info: DeviceInfo, + coordinator: DataUpdateCoordinator, + ) -> None: + """Initialize an Idasen Desk cover.""" + super().__init__(coordinator) + self._desk = desk + self._attr_name = device_info[ATTR_NAME] + self._attr_unique_id = address + self._attr_device_info = device_info + + self._attr_current_cover_position = self._desk.height_percent + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._desk.is_connected is True + + @property + def is_closed(self) -> bool: + """Return if the cover is closed.""" + return self.current_cover_position == 0 + + async def async_close_cover(self, **kwargs: Any) -> None: + """Close the cover.""" + await self._desk.move_down() + + async def async_open_cover(self, **kwargs: Any) -> None: + """Open the cover.""" + await self._desk.move_up() + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Stop the cover.""" + await self._desk.stop() + + async def async_set_cover_position(self, **kwargs: Any) -> None: + """Move the cover shutter to a specific position.""" + await self._desk.move_to(int(kwargs[ATTR_POSITION])) + + @callback + def _handle_coordinator_update(self, *args: Any) -> None: + """Handle data update.""" + self._attr_current_cover_position = self._desk.height_percent + self.async_write_ha_state() diff --git a/homeassistant/components/idasen_desk/manifest.json b/homeassistant/components/idasen_desk/manifest.json new file mode 100644 index 00000000000000..f77e0c22373397 --- /dev/null +++ b/homeassistant/components/idasen_desk/manifest.json @@ -0,0 +1,15 @@ +{ + "domain": "idasen_desk", + "name": "IKEA Idasen Desk", + "bluetooth": [ + { + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a" + } + ], + "codeowners": ["@abmantis"], + "config_flow": true, + "dependencies": ["bluetooth_adapters"], + "documentation": "https://www.home-assistant.io/integrations/idasen_desk", + "iot_class": "local_push", + "requirements": ["idasen-ha==1.4"] +} diff --git a/homeassistant/components/idasen_desk/strings.json b/homeassistant/components/idasen_desk/strings.json new file mode 100644 index 00000000000000..e2be7e6deff147 --- /dev/null +++ b/homeassistant/components/idasen_desk/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "flow_title": "{name}", + "step": { + "user": { + "data": { + "address": "Bluetooth address" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "not_supported": "Device not supported", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]" + } + } +} diff --git a/homeassistant/generated/bluetooth.py b/homeassistant/generated/bluetooth.py index 7b0aa78d69e06f..5784667bc675d6 100644 --- a/homeassistant/generated/bluetooth.py +++ b/homeassistant/generated/bluetooth.py @@ -213,6 +213,10 @@ ], "manufacturer_id": 76, }, + { + "domain": "idasen_desk", + "service_uuid": "99fa0001-338a-1024-8a49-009c0215f78a", + }, { "connectable": False, "domain": "inkbird", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 0d20e80317c3b9..3f37f3a19dfcb4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -210,6 +210,7 @@ "iaqualink", "ibeacon", "icloud", + "idasen_desk", "ifttt", "imap", "inkbird", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d1efd527b697dd..966cf186346e15 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -2578,6 +2578,12 @@ "config_flow": true, "iot_class": "local_polling", "name": "IKEA TR\u00c5DFRI" + }, + "idasen_desk": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "IKEA Idasen Desk" } } }, diff --git a/mypy.ini b/mypy.ini index d2c2a66d738aa6..67390ef2ddf32a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1562,6 +1562,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.idasen_desk.*] +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.image.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 2a9f39baf4cf98..49806f29942a4e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1042,6 +1042,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bac718dc57911..51195c7cecdc1c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -819,6 +819,9 @@ ical==5.0.1 # homeassistant.components.ping icmplib==3.0 +# homeassistant.components.idasen_desk +idasen-ha==1.4 + # homeassistant.components.network ifaddr==0.2.0 diff --git a/tests/components/idasen_desk/__init__.py b/tests/components/idasen_desk/__init__.py new file mode 100644 index 00000000000000..7e8becc4689c00 --- /dev/null +++ b/tests/components/idasen_desk/__init__.py @@ -0,0 +1,51 @@ +"""Tests for the IKEA Idasen Desk integration.""" + +from homeassistant.components.bluetooth import BluetoothServiceInfoBleak +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.components.bluetooth import generate_advertisement_data, generate_ble_device + +IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Desk 1234", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=["99fa0001-338a-1024-8a49-009c0215f78a"], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Desk 1234"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + +NOT_IDASEN_DISCOVERY_INFO = BluetoothServiceInfoBleak( + name="Not Desk", + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + manufacturer_data={}, + service_uuids=[], + service_data={}, + source="local", + device=generate_ble_device(address="AA:BB:CC:DD:EE:FF", name="Not Desk"), + advertisement=generate_advertisement_data(), + time=0, + connectable=True, +) + + +async def init_integration(hass: HomeAssistant) -> MockConfigEntry: + """Set up the IKEA Idasen Desk integration in Home Assistant.""" + entry = MockConfigEntry( + title="Test", + domain=DOMAIN, + data={CONF_ADDRESS: "AA:BB:CC:DD:EE:FF"}, + ) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry diff --git a/tests/components/idasen_desk/conftest.py b/tests/components/idasen_desk/conftest.py new file mode 100644 index 00000000000000..736bc6346ceaa8 --- /dev/null +++ b/tests/components/idasen_desk/conftest.py @@ -0,0 +1,49 @@ +"""IKEA Idasen Desk fixtures.""" + +from collections.abc import Callable +from unittest import mock +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +@pytest.fixture(autouse=True) +def mock_bluetooth(enable_bluetooth): + """Auto mock bluetooth.""" + + +@pytest.fixture(autouse=False) +def mock_desk_api(): + """Set up idasen desk API fixture.""" + with mock.patch("homeassistant.components.idasen_desk.Desk") as desk_patched: + mock_desk = MagicMock() + + def mock_init(update_callback: Callable[[int | None], None] | None): + 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): + mock_desk.is_connected = True + + async def mock_move_to(height: float): + mock_desk.height_percent = height + mock_desk.trigger_update_callback(height) + + async def mock_move_up(): + await mock_move_to(100) + + async def mock_move_down(): + await mock_move_to(0) + + mock_desk.connect = AsyncMock(side_effect=mock_connect) + mock_desk.disconnect = AsyncMock() + 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 + + yield mock_desk diff --git a/tests/components/idasen_desk/test_config_flow.py b/tests/components/idasen_desk/test_config_flow.py new file mode 100644 index 00000000000000..8635e5bfddcdb5 --- /dev/null +++ b/tests/components/idasen_desk/test_config_flow.py @@ -0,0 +1,230 @@ +"""Test the IKEA Idasen Desk config flow.""" +from unittest.mock import patch + +from bleak import BleakError +import pytest + +from homeassistant import config_entries +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.const import CONF_ADDRESS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import IDASEN_DISCOVERY_INFO, NOT_IDASEN_DISCOVERY_INFO + +from tests.common import MockConfigEntry + + +async def test_user_step_success(hass: HomeAssistant) -> None: + """Test user step success path.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, 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"), patch( + "homeassistant.components.idasen_desk.config_flow.Desk.disconnect" + ), patch( + "homeassistant.components.idasen_desk.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + 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.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_user_step_no_devices_found(hass: HomeAssistant) -> None: + """Test user step with no devices found.""" + with patch( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_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_user_step_no_new_devices_found(hass: HomeAssistant) -> None: + """Test user step with only existing devices found.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + }, + unique_id=IDASEN_DISCOVERY_INFO.address, + ) + entry.add_to_hass(hass) + 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.ABORT + assert result["reason"] == "no_devices_found" + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_user_step_cannot_connect( + hass: HomeAssistant, exception: Exception +) -> None: + """Test user step and we cannot connect.""" + 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=exception, + ), 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": "cannot_connect"} + + 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( + "homeassistant.components.idasen_desk.config_flow.async_discovered_service_info", + return_value=[NOT_IDASEN_DISCOVERY_INFO, 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=RuntimeError, + ), 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": "unknown"} + + 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_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=IDASEN_DISCOVERY_INFO, + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + 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: + 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.CREATE_ENTRY + assert result2["title"] == IDASEN_DISCOVERY_INFO.name + assert result2["data"] == { + CONF_ADDRESS: IDASEN_DISCOVERY_INFO.address, + } + assert result2["result"].unique_id == IDASEN_DISCOVERY_INFO.address + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/idasen_desk/test_cover.py b/tests/components/idasen_desk/test_cover.py new file mode 100644 index 00000000000000..a9c74be7081ae5 --- /dev/null +++ b/tests/components/idasen_desk/test_cover.py @@ -0,0 +1,82 @@ +"""Test the IKEA Idasen Desk cover.""" +from typing import Any +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.cover import ( + ATTR_CURRENT_POSITION, + ATTR_POSITION, + DOMAIN as COVER_DOMAIN, +) +from homeassistant.const import ( + SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, + SERVICE_SET_COVER_POSITION, + SERVICE_STOP_COVER, + STATE_CLOSED, + STATE_OPEN, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_cover_available( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test cover available property.""" + entity_id = "cover.test" + await init_integration(hass) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + + mock_desk_api.is_connected = False + mock_desk_api.trigger_update_callback(None) + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("service", "service_data", "expected_state", "expected_position"), + [ + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 100}, STATE_OPEN, 100), + (SERVICE_SET_COVER_POSITION, {ATTR_POSITION: 0}, STATE_CLOSED, 0), + (SERVICE_OPEN_COVER, {}, STATE_OPEN, 100), + (SERVICE_CLOSE_COVER, {}, STATE_CLOSED, 0), + (SERVICE_STOP_COVER, {}, STATE_OPEN, 60), + ], +) +async def test_cover_services( + hass: HomeAssistant, + mock_desk_api: MagicMock, + service: str, + service_data: dict[str, Any], + expected_state: str, + expected_position: int, +) -> None: + """Test cover services.""" + entity_id = "cover.test" + await init_integration(hass) + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_OPEN + assert state.attributes[ATTR_CURRENT_POSITION] == 60 + await hass.services.async_call( + COVER_DOMAIN, + service, + {"entity_id": entity_id, **service_data}, + blocking=True, + ) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + assert state + assert state.state == expected_state + assert state.attributes[ATTR_CURRENT_POSITION] == expected_position diff --git a/tests/components/idasen_desk/test_init.py b/tests/components/idasen_desk/test_init.py new file mode 100644 index 00000000000000..e596f0fe000b15 --- /dev/null +++ b/tests/components/idasen_desk/test_init.py @@ -0,0 +1,55 @@ +"""Test the IKEA Idasen Desk init.""" +from unittest.mock import AsyncMock, MagicMock + +from bleak import BleakError +import pytest + +from homeassistant.components.idasen_desk.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import HomeAssistant + +from . import init_integration + + +async def test_setup_and_shutdown( + hass: HomeAssistant, + mock_desk_api: MagicMock, +) -> None: + """Test setup.""" + entry = await init_integration(hass) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + +@pytest.mark.parametrize("exception", [TimeoutError(), BleakError()]) +async def test_setup_connect_exception( + hass: HomeAssistant, mock_desk_api: MagicMock, exception: Exception +) -> None: + """Test setup with an connection exception.""" + mock_desk_api.connect = AsyncMock(side_effect=exception) + 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) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state is ConfigEntryState.LOADED + mock_desk_api.connect.assert_called_once() + mock_desk_api.is_connected = True + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + mock_desk_api.disconnect.assert_called_once() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 From 03af4679182f5eff9fa8f6bca0620e39ede4c151 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Sep 2023 08:47:20 +0200 Subject: [PATCH 608/640] Move renson coordinator to its own file (#100610) --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 35 +--------------- .../components/renson/binary_sensor.py | 2 +- .../components/renson/coordinator.py | 41 +++++++++++++++++++ homeassistant/components/renson/entity.py | 2 +- homeassistant/components/renson/fan.py | 2 +- homeassistant/components/renson/number.py | 2 +- homeassistant/components/renson/sensor.py | 3 +- 8 files changed, 49 insertions(+), 39 deletions(-) create mode 100644 homeassistant/components/renson/coordinator.py diff --git a/.coveragerc b/.coveragerc index 73ae1d1a466014..2a75526e63a400 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1011,6 +1011,7 @@ omit = homeassistant/components/rainmachine/util.py homeassistant/components/renson/__init__.py homeassistant/components/renson/const.py + homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py homeassistant/components/renson/fan.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 7ce143d8a214f1..231e63bfc25491 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -1,11 +1,7 @@ """The Renson integration.""" from __future__ import annotations -import asyncio from dataclasses import dataclass -from datetime import timedelta -import logging -from typing import Any from renson_endura_delta.renson import RensonVentilation @@ -13,11 +9,9 @@ from homeassistant.const import CONF_HOST, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import RensonCoordinator PLATFORMS = [ Platform.BINARY_SENSOR, @@ -62,30 +56,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class RensonCoordinator(DataUpdateCoordinator): - """Data update coordinator for Renson.""" - - def __init__( - self, - name: str, - hass: HomeAssistant, - api: RensonVentilation, - update_interval=timedelta(seconds=30), - ) -> None: - """Initialize my coordinator.""" - super().__init__( - hass, - _LOGGER, - # Name of the data. For logging purposes. - name=name, - # Polling interval. Will only be polled if there are subscribers. - update_interval=update_interval, - ) - self.api = api - - async def _async_update_data(self) -> dict[str, Any]: - """Fetch data from API endpoint.""" - async with asyncio.timeout(30): - return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/binary_sensor.py b/homeassistant/components/renson/binary_sensor.py index cad8b92c0c3948..39c2b1b883d342 100644 --- a/homeassistant/components/renson/binary_sensor.py +++ b/homeassistant/components/renson/binary_sensor.py @@ -25,8 +25,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity diff --git a/homeassistant/components/renson/coordinator.py b/homeassistant/components/renson/coordinator.py new file mode 100644 index 00000000000000..924a3b765f56ba --- /dev/null +++ b/homeassistant/components/renson/coordinator.py @@ -0,0 +1,41 @@ +"""DataUpdateCoordinator for the renson integration.""" +from __future__ import annotations + +import asyncio +from datetime import timedelta +import logging +from typing import Any + +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class RensonCoordinator(DataUpdateCoordinator[dict[str, Any]]): + """Data update coordinator for Renson.""" + + def __init__( + self, + name: str, + hass: HomeAssistant, + api: RensonVentilation, + update_interval=timedelta(seconds=30), + ) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=name, + # Polling interval. Will only be polled if there are subscribers. + update_interval=update_interval, + ) + self.api = api + + async def _async_update_data(self) -> dict[str, Any]: + """Fetch data from API endpoint.""" + async with asyncio.timeout(30): + return await self.hass.async_add_executor_job(self.api.get_all_data) diff --git a/homeassistant/components/renson/entity.py b/homeassistant/components/renson/entity.py index 245b55d661196b..9bb2c27b112276 100644 --- a/homeassistant/components/renson/entity.py +++ b/homeassistant/components/renson/entity.py @@ -12,8 +12,8 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator class RensonEntity(CoordinatorEntity[RensonCoordinator]): diff --git a/homeassistant/components/renson/fan.py b/homeassistant/components/renson/fan.py index 0fe639d40ec8f2..da6850859a6696 100644 --- a/homeassistant/components/renson/fan.py +++ b/homeassistant/components/renson/fan.py @@ -18,8 +18,8 @@ ranged_value_to_percentage, ) -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/renson/number.py b/homeassistant/components/renson/number.py index bf33b75c9e36e8..344fa3ff0bd984 100644 --- a/homeassistant/components/renson/number.py +++ b/homeassistant/components/renson/number.py @@ -16,8 +16,8 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/renson/sensor.py b/homeassistant/components/renson/sensor.py index 661ab82f37397d..b729e2969d634b 100644 --- a/homeassistant/components/renson/sensor.py +++ b/homeassistant/components/renson/sensor.py @@ -46,8 +46,9 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback -from . import RensonCoordinator, RensonData +from . import RensonData from .const import DOMAIN +from .coordinator import RensonCoordinator from .entity import RensonEntity OPTIONS_MAPPING = { From 7af62c35f505837966dc9af78730dcbf22d1727a Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Wed, 20 Sep 2023 08:59:49 +0200 Subject: [PATCH 609/640] Move faa_delays coordinator to its own file (#100548) --- .coveragerc | 1 + .../components/faa_delays/__init__.py | 33 +---------------- .../components/faa_delays/coordinator.py | 35 +++++++++++++++++++ 3 files changed, 37 insertions(+), 32 deletions(-) create mode 100644 homeassistant/components/faa_delays/coordinator.py diff --git a/.coveragerc b/.coveragerc index 2a75526e63a400..ac08240fd0f38e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -357,6 +357,7 @@ omit = homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py + homeassistant/components/faa_delays/coordinator.py homeassistant/components/familyhub/camera.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py index b165492d07631c..3606da334990cb 100644 --- a/homeassistant/components/faa_delays/__init__.py +++ b/homeassistant/components/faa_delays/__init__.py @@ -1,20 +1,10 @@ """The FAA Delays integration.""" -import asyncio -from datetime import timedelta -import logging - -from aiohttp import ClientConnectionError -from faadelays import Airport - from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ID, Platform from homeassistant.core import HomeAssistant -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import FAADataUpdateCoordinator PLATFORMS = [Platform.BINARY_SENSOR] @@ -40,24 +30,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class FAADataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching FAA API data from a single endpoint.""" - - def __init__(self, hass, code): - """Initialize the coordinator.""" - super().__init__( - hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) - ) - self.session = aiohttp_client.async_get_clientsession(hass) - self.data = Airport(code, self.session) - self.code = code - - async def _async_update_data(self): - try: - async with asyncio.timeout(10): - await self.data.update() - except ClientConnectionError as err: - raise UpdateFailed(err) from err - return self.data diff --git a/homeassistant/components/faa_delays/coordinator.py b/homeassistant/components/faa_delays/coordinator.py new file mode 100644 index 00000000000000..f2aefdada66c1c --- /dev/null +++ b/homeassistant/components/faa_delays/coordinator.py @@ -0,0 +1,35 @@ +"""DataUpdateCoordinator for faa_delays integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from faadelays import Airport + +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class FAADataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching FAA API data from a single endpoint.""" + + def __init__(self, hass, code): + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) + ) + self.session = aiohttp_client.async_get_clientsession(hass) + self.data = Airport(code, self.session) + self.code = code + + async def _async_update_data(self): + try: + async with asyncio.timeout(10): + await self.data.update() + except ClientConnectionError as err: + raise UpdateFailed(err) from err + return self.data From 33f748493e19a9a900d8ac8cf28cbc2ed21081d3 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 09:49:16 +0200 Subject: [PATCH 610/640] Update enphase_envoy zeroconf checks to use stdlib ipaddress methods (#100624) --- homeassistant/components/enphase_envoy/config_flow.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/enphase_envoy/config_flow.py b/homeassistant/components/enphase_envoy/config_flow.py index b41d29626e70ab..999542ee2a5e66 100644 --- a/homeassistant/components/enphase_envoy/config_flow.py +++ b/homeassistant/components/enphase_envoy/config_flow.py @@ -15,7 +15,6 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult from homeassistant.helpers.httpx_client import get_async_client -from homeassistant.util.network import is_ipv4_address from .const import DOMAIN, INVALID_AUTH_ERRORS @@ -90,7 +89,7 @@ async def async_step_zeroconf( self, discovery_info: zeroconf.ZeroconfServiceInfo ) -> FlowResult: """Handle a flow initialized by zeroconf discovery.""" - if not is_ipv4_address(discovery_info.host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") serial = discovery_info.properties["serialnum"] self.protovers = discovery_info.properties.get("protovers") From 06c7f0959c3edb6920d6507811d6571ddd586ca7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 11:54:24 +0200 Subject: [PATCH 611/640] Update dhcp to use stdlib ipaddress methods (#100625) --- homeassistant/components/dhcp/__init__.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/dhcp/__init__.py b/homeassistant/components/dhcp/__init__.py index 29b25d0781be1f..c3705dad3ddbbe 100644 --- a/homeassistant/components/dhcp/__init__.py +++ b/homeassistant/components/dhcp/__init__.py @@ -58,7 +58,6 @@ from homeassistant.helpers.typing import ConfigType, EventType from homeassistant.loader import DHCPMatcher, async_get_dhcp from homeassistant.util.async_ import run_callback_threadsafe -from homeassistant.util.network import is_invalid, is_link_local, is_loopback from .const import DOMAIN @@ -162,9 +161,9 @@ def async_process_client( made_ip_address = make_ip_address(ip_address) if ( - is_link_local(made_ip_address) - or is_loopback(made_ip_address) - or is_invalid(made_ip_address) + made_ip_address.is_link_local + or made_ip_address.is_loopback + or made_ip_address.is_unspecified ): # Ignore self assigned addresses, loopback, invalid return From d675825b5ae697038506a531c9aa31e0ebf4a45f Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 11:55:51 +0200 Subject: [PATCH 612/640] Avoid double lookups with data_entry_flow indices (#100627) --- homeassistant/data_entry_flow.py | 35 +++++++++++++------------------- 1 file changed, 14 insertions(+), 21 deletions(-) diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 467fc3b522886b..63cbfda5b9bd5a 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -138,8 +138,8 @@ def __init__( self.hass = hass self._preview: set[str] = set() self._progress: dict[str, FlowHandler] = {} - self._handler_progress_index: dict[str, set[str]] = {} - self._init_data_process_index: dict[type, set[str]] = {} + self._handler_progress_index: dict[str, set[FlowHandler]] = {} + self._init_data_process_index: dict[type, set[FlowHandler]] = {} @abc.abstractmethod async def async_create_flow( @@ -221,9 +221,9 @@ def async_progress_by_init_data_type( """Return flows in progress init matching by data type as a partial FlowResult.""" return _async_flow_handler_to_flow_result( ( - self._progress[flow_id] - for flow_id in self._init_data_process_index.get(init_data_type, {}) - if matcher(self._progress[flow_id].init_data) + progress + for progress in self._init_data_process_index.get(init_data_type, set()) + if matcher(progress.init_data) ), include_uninitialized, ) @@ -237,18 +237,13 @@ def _async_progress_by_handler( If match_context is specified, only return flows with a context that is a superset of match_context. """ - match_context_items = match_context.items() if match_context else None + if not match_context: + return list(self._handler_progress_index.get(handler, [])) + match_context_items = match_context.items() return [ progress - for flow_id in self._handler_progress_index.get(handler, {}) - if (progress := self._progress[flow_id]) - and ( - not match_context_items - or ( - (context := progress.context) - and match_context_items <= context.items() - ) - ) + for progress in self._handler_progress_index.get(handler, set()) + if match_context_items <= progress.context.items() ] async def async_init( @@ -348,22 +343,20 @@ def _async_add_flow_progress(self, flow: FlowHandler) -> None: """Add a flow to in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index.setdefault(init_data_type, set()).add( - flow.flow_id - ) + self._init_data_process_index.setdefault(init_data_type, set()).add(flow) self._progress[flow.flow_id] = flow - self._handler_progress_index.setdefault(flow.handler, set()).add(flow.flow_id) + self._handler_progress_index.setdefault(flow.handler, set()).add(flow) @callback def _async_remove_flow_from_index(self, flow: FlowHandler) -> None: """Remove a flow from in progress.""" if flow.init_data is not None: init_data_type = type(flow.init_data) - self._init_data_process_index[init_data_type].remove(flow.flow_id) + self._init_data_process_index[init_data_type].remove(flow) if not self._init_data_process_index[init_data_type]: del self._init_data_process_index[init_data_type] handler = flow.handler - self._handler_progress_index[handler].remove(flow.flow_id) + self._handler_progress_index[handler].remove(flow) if not self._handler_progress_index[handler]: del self._handler_progress_index[handler] From 7014ed34534fd4373e394ae436b8ed1a9d26d68e Mon Sep 17 00:00:00 2001 From: Robin Li Date: Wed, 20 Sep 2023 07:53:05 -0400 Subject: [PATCH 613/640] Fix ecobee aux_heat_off always returns to HEAT (#100630) --- homeassistant/components/ecobee/climate.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/ecobee/climate.py b/homeassistant/components/ecobee/climate.py index b18f646add78d4..e1253b585acee8 100644 --- a/homeassistant/components/ecobee/climate.py +++ b/homeassistant/components/ecobee/climate.py @@ -326,6 +326,7 @@ def __init__( self._attr_unique_id = self.thermostat["identifier"] self.vacation = None self._last_active_hvac_mode = HVACMode.HEAT_COOL + self._last_hvac_mode_before_aux_heat = HVACMode.HEAT_COOL self._attr_hvac_modes = [] if self.settings["heatStages"] or self.settings["hasHeatPump"]: @@ -541,13 +542,14 @@ def is_aux_heat(self) -> bool: def turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" _LOGGER.debug("Setting HVAC mode to auxHeatOnly to turn on aux heat") + self._last_hvac_mode_before_aux_heat = self.hvac_mode self.data.ecobee.set_hvac_mode(self.thermostat_index, ECOBEE_AUX_HEAT_ONLY) self.update_without_throttle = True def turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" _LOGGER.debug("Setting HVAC mode to last mode to disable aux heat") - self.set_hvac_mode(self._last_active_hvac_mode) + self.set_hvac_mode(self._last_hvac_mode_before_aux_heat) self.update_without_throttle = True def set_preset_mode(self, preset_mode: str) -> None: From 8b5129a7d92dcae8bff6a34f73484592e7a97ba2 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 13:58:34 +0200 Subject: [PATCH 614/640] Bump dbus-fast to 2.9.0 (#100638) --- 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 54f10fbc0c7cb7..56b06cd9d35d0c 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.11.0", - "dbus-fast==2.7.0" + "dbus-fast==2.9.0" ] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index df72b224c63117..77327fc6c800e2 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -16,7 +16,7 @@ bluetooth-data-tools==1.11.0 certifi>=2021.5.30 ciso8601==2.3.0 cryptography==41.0.3 -dbus-fast==2.7.0 +dbus-fast==2.9.0 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 49806f29942a4e..0ef33a0547682a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -646,7 +646,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.7.0 +dbus-fast==2.9.0 # homeassistant.components.debugpy debugpy==1.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 51195c7cecdc1c..610e875d2d3b3f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -529,7 +529,7 @@ datadog==0.15.0 datapoint==0.9.8 # homeassistant.components.bluetooth -dbus-fast==2.7.0 +dbus-fast==2.9.0 # homeassistant.components.debugpy debugpy==1.8.0 From 6f8734167feea9b839ea0d45bceae79fd329f5c9 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 16:19:53 +0200 Subject: [PATCH 615/640] Bump SQLAlchemy to 2.0.21 (#99745) --- .github/workflows/wheels.yml | 8 +- .../components/recorder/history/legacy.py | 2 +- .../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 +- .../recorder/test_migration_from_schema_32.py | 101 +++++++++++------- 8 files changed, 70 insertions(+), 51 deletions(-) diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index 6d947f51acaf3c..7636d628e411f0 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -56,7 +56,7 @@ jobs: echo "CI_BUILD=1" echo "ENABLE_HEADLESS=1" - # Use C-Extension for sqlalchemy + # Use C-Extension for SQLAlchemy echo "REQUIRE_SQLALCHEMY_CEXT=1" ) > .env_file @@ -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;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;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;grpcio;SQLAlchemy;protobuf constraints: "homeassistant/package_constraints.txt" requirements-diff: "requirements_diff.txt" requirements: "requirements_all.txtac" diff --git a/homeassistant/components/recorder/history/legacy.py b/homeassistant/components/recorder/history/legacy.py index 191c74ac0d44d0..2e1b02a8b64325 100644 --- a/homeassistant/components/recorder/history/legacy.py +++ b/homeassistant/components/recorder/history/legacy.py @@ -50,7 +50,7 @@ States.last_changed_ts, States.last_updated_ts, ) -_BASE_STATES_NO_LAST_CHANGED = ( # type: ignore[var-annotated] +_BASE_STATES_NO_LAST_CHANGED = ( States.entity_id, States.state, literal(value=None).label("last_changed_ts"), diff --git a/homeassistant/components/recorder/manifest.json b/homeassistant/components/recorder/manifest.json index 63b19cdb3bfb59..f40797fe38cf69 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.15", + "SQLAlchemy==2.0.21", "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 44de8fc6923611..7424807c804afe 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.15"] + "requirements": ["SQLAlchemy==2.0.21"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 77327fc6c800e2..b1ad7f7a3c5404 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.15 +SQLAlchemy==2.0.21 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 0ef33a0547682a..8eb3b3240646f9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -129,7 +129,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.travisci TravisPy==0.3.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 610e875d2d3b3f..d6d9bb9242e354 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -116,7 +116,7 @@ RtmAPI==0.7.2 # homeassistant.components.recorder # homeassistant.components.sql -SQLAlchemy==2.0.15 +SQLAlchemy==2.0.21 # homeassistant.components.onvif WSDiscovery==2.0.0 diff --git a/tests/components/recorder/test_migration_from_schema_32.py b/tests/components/recorder/test_migration_from_schema_32.py index cdf930fde261c1..e007d2408dd70e 100644 --- a/tests/components/recorder/test_migration_from_schema_32.py +++ b/tests/components/recorder/test_migration_from_schema_32.py @@ -39,6 +39,12 @@ ORIG_TZ = dt_util.DEFAULT_TIME_ZONE +async def _async_wait_migration_done(hass: HomeAssistant) -> None: + """Wait for the migration to be done.""" + await recorder.get_instance(hass).async_block_till_done() + await async_recorder_block_till_done(hass) + + def _create_engine_test(*args, **kwargs): """Test version of create_engine that initializes with old schema. @@ -101,6 +107,8 @@ async def test_migrate_events_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -110,7 +118,7 @@ def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="old_uuid_context_id_event", event_data=None, origin_idx=0, @@ -123,7 +131,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="empty_context_id_event", event_data=None, origin_idx=0, @@ -136,7 +144,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="ulid_context_id_event", event_data=None, origin_idx=0, @@ -149,7 +157,7 @@ def _insert_events(): context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="invalid_context_id_event", event_data=None, origin_idx=0, @@ -162,7 +170,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="garbage_context_id_event", event_data=None, origin_idx=0, @@ -175,7 +183,7 @@ def _insert_events(): context_parent_id=None, context_parent_id_bin=None, ), - Events( + old_db_schema.Events( event_type="event_with_garbage_context_id_no_time_fired_ts", event_data=None, origin_idx=0, @@ -196,10 +204,12 @@ def _insert_events(): await async_wait_recording_done(hass) now = dt_util.utcnow() expected_ulid_fallback_start = ulid_to_bytes(ulid_at_time(now.timestamp()))[0:6] + await _async_wait_migration_done(hass) + with freeze_time(now): # This is a threadsafe way to add a task to the recorder instance.queue_task(EventsContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -304,6 +314,8 @@ async def test_migrate_states_context_ids( """Test we can migrate old uuid context ids and ulid context ids to binary format.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] test_uuid = uuid.uuid4() uuid_hex = test_uuid.hex @@ -313,7 +325,7 @@ def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="state.old_uuid_context_id", last_updated_ts=1477721632.452529, context_id=uuid_hex, @@ -323,7 +335,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.empty_context_id", last_updated_ts=1477721632.552529, context_id=None, @@ -333,7 +345,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.ulid_context_id", last_updated_ts=1477721632.552529, context_id="01ARZ3NDEKTSV4RRFFQ69G5FAV", @@ -343,7 +355,7 @@ def _insert_states(): context_parent_id="01ARZ3NDEKTSV4RRFFQ69G5FA2", context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.invalid_context_id", last_updated_ts=1477721632.552529, context_id="invalid", @@ -353,7 +365,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.garbage_context_id", last_updated_ts=1477721632.552529, context_id="adapt_lgt:b'5Cf*':interval:b'0R'", @@ -363,7 +375,7 @@ def _insert_states(): context_parent_id=None, context_parent_id_bin=None, ), - States( + old_db_schema.States( entity_id="state.human_readable_uuid_context_id", last_updated_ts=1477721632.552529, context_id="0ae29799-ee4e-4f45-8116-f582d7d3ee65", @@ -380,7 +392,7 @@ def _insert_states(): await async_wait_recording_done(hass) instance.queue_task(StatesContextIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _object_as_dict(obj): return {c.key: getattr(obj, c.key) for c in inspect(obj).mapper.column_attrs} @@ -489,22 +501,24 @@ async def test_migrate_event_type_ids( """Test we can migrate event_types to the EventTypes table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.452529, ), - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1677721632.552529, ), - Events( + old_db_schema.Events( event_type="event_type_two", origin_idx=0, time_fired_ts=1677721632.552529, @@ -517,7 +531,7 @@ def _insert_events(): await async_wait_recording_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: @@ -570,22 +584,24 @@ async def test_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -595,10 +611,10 @@ def _insert_states(): await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -636,22 +652,24 @@ async def test_post_migrate_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add_all( ( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_2", last_updated_ts=2.252529, ), - States( + old_db_schema.States( entity_id="sensor.two", state="two_1", last_updated_ts=3.152529, @@ -661,10 +679,10 @@ def _insert_events(): await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDPostMigrationTask()) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -688,18 +706,20 @@ async def test_migrate_null_entity_ids( """Test we can migrate entity_ids to the StatesMeta table.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_states(): with session_scope(hass=hass) as session: session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=1.452529, ), ) session.add_all( - States( + old_db_schema.States( entity_id=None, state="empty", last_updated_ts=time + 1.452529, @@ -707,7 +727,7 @@ def _insert_states(): for time in range(1000) ) session.add( - States( + old_db_schema.States( entity_id="sensor.one", state="one_1", last_updated_ts=2.452529, @@ -716,11 +736,10 @@ def _insert_states(): await instance.async_add_executor_job(_insert_states) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder instance.queue_task(EntityIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_states(): with session_scope(hass=hass, read_only=True) as session: @@ -758,18 +777,20 @@ async def test_migrate_null_event_type_ids( """Test we can migrate event_types to the EventTypes table when the event_type is NULL.""" instance = await async_setup_recorder_instance(hass) await async_wait_recording_done(hass) + importlib.import_module(SCHEMA_MODULE) + old_db_schema = sys.modules[SCHEMA_MODULE] def _insert_events(): with session_scope(hass=hass) as session: session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=1.452529, ), ) session.add_all( - Events( + old_db_schema.Events( event_type=None, origin_idx=0, time_fired_ts=time + 1.452529, @@ -777,7 +798,7 @@ def _insert_events(): for time in range(1000) ) session.add( - Events( + old_db_schema.Events( event_type="event_type_one", origin_idx=0, time_fired_ts=2.452529, @@ -786,12 +807,10 @@ def _insert_events(): await instance.async_add_executor_job(_insert_events) - await async_wait_recording_done(hass) + await _async_wait_migration_done(hass) # This is a threadsafe way to add a task to the recorder - instance.queue_task(EventTypeIDMigrationTask()) - await async_recorder_block_till_done(hass) - await async_recorder_block_till_done(hass) + await _async_wait_migration_done(hass) def _fetch_migrated_events(): with session_scope(hass=hass, read_only=True) as session: From 77001b26debeb80bd42e22e863be9ad4e982a03a Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 20 Sep 2023 11:17:32 -0400 Subject: [PATCH 616/640] Add second test device for Roborock (#100565) --- tests/components/roborock/conftest.py | 34 ++- tests/components/roborock/mock_data.py | 53 +++- .../roborock/snapshots/test_diagnostics.ambr | 265 +++++++++++++++++- .../components/roborock/test_binary_sensor.py | 2 +- tests/components/roborock/test_init.py | 2 +- tests/components/roborock/test_sensor.py | 2 +- 6 files changed, 329 insertions(+), 29 deletions(-) diff --git a/tests/components/roborock/conftest.py b/tests/components/roborock/conftest.py index ef841769f8d0ce..3435bd58cb39f6 100644 --- a/tests/components/roborock/conftest.py +++ b/tests/components/roborock/conftest.py @@ -24,9 +24,21 @@ def bypass_api_fixture() -> None: "homeassistant.components.roborock.RoborockMqttClient.async_connect" ), patch( "homeassistant.components.roborock.RoborockMqttClient._send_command" + ), patch( + "homeassistant.components.roborock.RoborockApiClient.get_home_data", + return_value=HOME_DATA, + ), patch( + "homeassistant.components.roborock.RoborockMqttClient.get_networking", + return_value=NETWORK_INFO, ), patch( "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", return_value=PROP, + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" + ), patch( + "homeassistant.components.roborock.RoborockMqttClient._wait_response" + ), patch( + "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" ), patch( "roborock.api.AttributeCache.async_value" ), patch( @@ -53,25 +65,11 @@ def mock_roborock_entry(hass: HomeAssistant) -> MockConfigEntry: @pytest.fixture async def setup_entry( - hass: HomeAssistant, mock_roborock_entry: MockConfigEntry + hass: HomeAssistant, + bypass_api_fixture, + mock_roborock_entry: MockConfigEntry, ) -> MockConfigEntry: """Set up the Roborock platform.""" - with patch( - "homeassistant.components.roborock.RoborockApiClient.get_home_data", - return_value=HOME_DATA, - ), patch( - "homeassistant.components.roborock.RoborockMqttClient.get_networking", - return_value=NETWORK_INFO, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.get_prop", - return_value=PROP, - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient.send_message" - ), patch( - "homeassistant.components.roborock.RoborockMqttClient._wait_response" - ), patch( - "homeassistant.components.roborock.coordinator.RoborockLocalClient._wait_response" - ): - assert await async_setup_component(hass, DOMAIN, {}) + assert await async_setup_component(hass, DOMAIN, {}) await hass.async_block_till_done() return mock_roborock_entry diff --git a/tests/components/roborock/mock_data.py b/tests/components/roborock/mock_data.py index 6a2e1f4b5f1849..87ed02bc3ecc0a 100644 --- a/tests/components/roborock/mock_data.py +++ b/tests/components/roborock/mock_data.py @@ -13,6 +13,9 @@ ) from roborock.roborock_typing import DeviceProp +from homeassistant.components.roborock import CONF_BASE_URL, CONF_USER_DATA +from homeassistant.const import CONF_USERNAME + # All data is based on a U.S. customer with a Roborock S7 MaxV Ultra USER_EMAIL = "user@domain.com" @@ -48,9 +51,9 @@ ) MOCK_CONFIG = { - "username": USER_EMAIL, - "user_data": USER_DATA.as_dict(), - "base_url": None, + CONF_USERNAME: USER_EMAIL, + CONF_USER_DATA: USER_DATA.as_dict(), + CONF_BASE_URL: None, } HOME_DATA_RAW = { @@ -61,7 +64,7 @@ "geoName": None, "products": [ { - "id": "abc123", + "id": "s7_product", "name": "Roborock S7 MaxV", "code": "a27", "model": "roborock.vacuum.a27", @@ -227,7 +230,7 @@ "runtimeEnv": None, "timeZoneId": "America/Los_Angeles", "iconUrl": "", - "productId": "abc123", + "productId": "s7_product", "lon": None, "lat": None, "share": False, @@ -255,7 +258,45 @@ "120": 0, }, "silentOtaSwitch": True, - } + }, + { + "duid": "device_2", + "name": "Roborock S7 2", + "attribute": None, + "activeTime": 1672364449, + "localKey": "device_2", + "runtimeEnv": None, + "timeZoneId": "America/Los_Angeles", + "iconUrl": "", + "productId": "s7_product", + "lon": None, + "lat": None, + "share": False, + "shareTime": None, + "online": True, + "fv": "02.56.02", + "pv": "1.0", + "roomId": 2362003, + "tuyaUuid": None, + "tuyaMigrated": False, + "extra": '{"RRPhotoPrivacyVersion": "1"}', + "sn": "abc123", + "featureSet": "2234201184108543", + "newFeatureSet": "0000000000002041", + "deviceStatus": { + "121": 8, + "122": 100, + "123": 102, + "124": 203, + "125": 94, + "126": 90, + "127": 87, + "128": 0, + "133": 1, + "120": 0, + }, + "silentOtaSwitch": True, + }, ], "receivedDevices": [], "rooms": [ diff --git a/tests/components/roborock/snapshots/test_diagnostics.ambr b/tests/components/roborock/snapshots/test_diagnostics.ambr index a766a6c27032b3..d8e5f7d4cb23ef 100644 --- a/tests/components/roborock/snapshots/test_diagnostics.ambr +++ b/tests/components/roborock/snapshots/test_diagnostics.ambr @@ -57,7 +57,7 @@ 'name': 'Roborock S7 MaxV', 'newFeatureSet': '0000000000002041', 'online': True, - 'productId': 'abc123', + 'productId': 's7_product', 'pv': '1.0', 'roomId': 2362003, 'share': False, @@ -77,7 +77,268 @@ 'capability': 0, 'category': 'robot.vacuum.cleaner', 'code': 'a27', - 'id': 'abc123', + 'id': 's7_product', + 'model': 'roborock.vacuum.a27', + 'name': 'Roborock S7 MaxV', + 'schema': list([ + dict({ + 'code': 'rpc_request', + 'id': '101', + 'mode': 'rw', + 'name': 'rpc_request', + 'type': 'RAW', + }), + dict({ + 'code': 'rpc_response', + 'id': '102', + 'mode': 'rw', + 'name': 'rpc_response', + 'type': 'RAW', + }), + dict({ + 'code': 'error_code', + 'id': '120', + 'mode': 'ro', + 'name': '错误代码', + 'type': 'ENUM', + }), + dict({ + 'code': 'state', + 'id': '121', + 'mode': 'ro', + 'name': '设备状态', + 'type': 'ENUM', + }), + dict({ + 'code': 'battery', + 'id': '122', + 'mode': 'ro', + 'name': '设备电量', + 'type': 'ENUM', + }), + dict({ + 'code': 'fan_power', + 'id': '123', + 'mode': 'rw', + 'name': '清扫模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'water_box_mode', + 'id': '124', + 'mode': 'rw', + 'name': '拖地模式', + 'type': 'ENUM', + }), + dict({ + 'code': 'main_brush_life', + 'id': '125', + 'mode': 'rw', + 'name': '主刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'side_brush_life', + 'id': '126', + 'mode': 'rw', + 'name': '边刷寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'filter_life', + 'id': '127', + 'mode': 'rw', + 'name': '滤网寿命', + 'type': 'VALUE', + }), + dict({ + 'code': 'additional_props', + 'id': '128', + 'mode': 'ro', + 'name': '额外状态', + 'type': 'RAW', + }), + dict({ + 'code': 'task_complete', + 'id': '130', + 'mode': 'ro', + 'name': '完成事件', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_low_power', + 'id': '131', + 'mode': 'ro', + 'name': '电量不足任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'task_cancel_in_motion', + 'id': '132', + 'mode': 'ro', + 'name': '运动中任务取消', + 'type': 'RAW', + }), + dict({ + 'code': 'charge_status', + 'id': '133', + 'mode': 'ro', + 'name': '充电状态', + 'type': 'RAW', + }), + dict({ + 'code': 'drying_status', + 'id': '134', + 'mode': 'ro', + 'name': '烘干状态', + 'type': 'RAW', + }), + ]), + }), + 'props': dict({ + 'cleanSummary': dict({ + 'cleanArea': 1159182500, + 'cleanCount': 31, + 'cleanTime': 74382, + 'dustCollectionCount': 25, + 'records': list([ + 1672543330, + 1672458041, + ]), + 'squareMeterCleanArea': 1159.2, + }), + 'consumable': dict({ + 'cleaningBrushWorkTimes': 65, + 'dustCollectionWorkTimes': 25, + 'filterElementWorkTime': 0, + 'filterTimeLeft': 465618, + 'filterWorkTime': 74382, + 'mainBrushTimeLeft': 1005618, + 'mainBrushWorkTime': 74382, + 'sensorDirtyTime': 74382, + 'sensorTimeLeft': 33618, + 'sideBrushTimeLeft': 645618, + 'sideBrushWorkTime': 74382, + 'strainerWorkTimes': 65, + }), + 'lastCleanRecord': dict({ + 'area': 20965000, + 'avoidCount': 19, + 'begin': 1672543330, + 'beginDatetime': '2023-01-01T03:22:10+00:00', + 'cleanType': 3, + 'complete': 1, + 'duration': 1176, + 'dustCollectionStatus': 1, + 'end': 1672544638, + 'endDatetime': '2023-01-01T03:43:58+00:00', + 'error': 0, + 'finishReason': 56, + 'mapFlag': 0, + 'squareMeterArea': 21.0, + 'startType': 2, + 'washCount': 2, + }), + 'status': dict({ + 'adbumperStatus': list([ + 0, + 0, + 0, + ]), + 'autoDustCollection': 1, + 'avoidCount': 19, + 'backType': -1, + 'battery': 100, + 'cameraStatus': 3457, + 'chargeStatus': 1, + 'cleanArea': 20965000, + 'cleanTime': 1176, + 'collisionAvoidStatus': 1, + 'debugMode': 0, + 'dndEnabled': 0, + 'dockErrorStatus': 0, + 'dockType': 3, + 'dustCollectionStatus': 0, + 'errorCode': 0, + 'fanPower': 102, + 'homeSecEnablePassword': 0, + 'homeSecStatus': 0, + 'inCleaning': 0, + 'inFreshState': 1, + 'inReturning': 0, + 'isExploring': 0, + 'isLocating': 0, + 'labStatus': 1, + 'lockStatus': 0, + 'mapPresent': 1, + 'mapStatus': 3, + 'mopForbiddenEnable': 1, + 'mopMode': 300, + 'msgSeq': 458, + 'msgVer': 2, + 'squareMeterCleanArea': 21.0, + 'state': 8, + 'switchMapMode': 0, + 'unsaveMapFlag': 0, + 'unsaveMapReason': 0, + 'washPhase': 0, + 'washReady': 0, + 'waterBoxCarriageStatus': 1, + 'waterBoxMode': 203, + 'waterBoxStatus': 1, + 'waterShortageStatus': 0, + }), + }), + }), + }), + '**REDACTED-1**': dict({ + 'api': dict({ + }), + 'roborock_device_info': dict({ + 'device': dict({ + 'activeTime': 1672364449, + 'deviceStatus': dict({ + '120': 0, + '121': 8, + '122': 100, + '123': 102, + '124': 203, + '125': 94, + '126': 90, + '127': 87, + '128': 0, + '133': 1, + }), + 'duid': '**REDACTED**', + 'extra': '{"RRPhotoPrivacyVersion": "1"}', + 'featureSet': '2234201184108543', + 'fv': '02.56.02', + 'iconUrl': '', + 'localKey': '**REDACTED**', + 'name': 'Roborock S7 2', + 'newFeatureSet': '0000000000002041', + 'online': True, + 'productId': 's7_product', + 'pv': '1.0', + 'roomId': 2362003, + 'share': False, + 'silentOtaSwitch': True, + 'sn': 'abc123', + 'timeZoneId': 'America/Los_Angeles', + 'tuyaMigrated': False, + }), + 'network_info': dict({ + 'bssid': '**REDACTED**', + 'ip': '123.232.12.1', + 'mac': '**REDACTED**', + 'rssi': 90, + 'ssid': 'wifi', + }), + 'product': dict({ + 'capability': 0, + 'category': 'robot.vacuum.cleaner', + 'code': 'a27', + 'id': 's7_product', 'model': 'roborock.vacuum.a27', 'name': 'Roborock S7 MaxV', 'schema': list([ diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index d4d415424bc45e..310643355b0ca5 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")) == 2 + assert len(hass.states.async_all("binary_sensor")) == 4 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 diff --git a/tests/components/roborock/test_init.py b/tests/components/roborock/test_init.py index 05bf0848475840..a5ad24b431c548 100644 --- a/tests/components/roborock/test_init.py +++ b/tests/components/roborock/test_init.py @@ -21,7 +21,7 @@ async def test_unload_entry( ) as mock_disconnect: assert await hass.config_entries.async_unload(setup_entry.entry_id) await hass.async_block_till_done() - assert mock_disconnect.call_count == 1 + assert mock_disconnect.call_count == 2 assert setup_entry.state is ConfigEntryState.NOT_LOADED assert not hass.data.get(DOMAIN) diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index a022f0dfa51400..0089c9a60bd661 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 12 + assert len(hass.states.async_all("sensor")) == 24 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) From ec5675ff4b3ce995cdacca1bb4af1a4c34a3f03e Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 17:37:13 +0200 Subject: [PATCH 617/640] Fix hkid matching in homekit_controller when zeroconf value is not upper case (#100641) --- .../homekit_controller/config_flow.py | 46 +++++------ .../homekit_controller/test_config_flow.py | 77 +++++++++++++++++++ 2 files changed, 100 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 988adbd87a7b86..088747d39ffc65 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -80,12 +80,12 @@ def formatted_category(category: Categories) -> str: @callback -def find_existing_host( - hass: HomeAssistant, serial: str +def find_existing_config_entry( + hass: HomeAssistant, upper_case_hkid: str ) -> config_entries.ConfigEntry | None: """Return a set of the configured hosts.""" for entry in hass.config_entries.async_entries(DOMAIN): - if entry.data.get("AccessoryPairingID") == serial: + if entry.data.get("AccessoryPairingID") == upper_case_hkid: return entry return None @@ -114,7 +114,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize the homekit_controller flow.""" self.model: str | None = None - self.hkid: str | None = None + self.hkid: str | None = None # This is always lower case self.name: str | None = None self.category: Categories | None = None self.devices: dict[str, AbstractDiscovery] = {} @@ -199,11 +199,12 @@ async def async_step_unignore(self, user_input: dict[str, Any]) -> FlowResult: return self._async_step_pair_show_form() - async def _hkid_is_homekit(self, hkid: str) -> bool: + @callback + def _hkid_is_homekit(self, hkid: str) -> bool: """Determine if the device is a homekit bridge or accessory.""" dev_reg = dr.async_get(self.hass) device = dev_reg.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, hkid)} + connections={(dr.CONNECTION_NETWORK_MAC, dr.format_mac(hkid))} ) if device is None: @@ -244,17 +245,10 @@ async def async_step_zeroconf( # The hkid is a unique random number that looks like a pairing code. # It changes if a device is factory reset. - hkid = properties[zeroconf.ATTR_PROPERTIES_ID] + hkid: str = properties[zeroconf.ATTR_PROPERTIES_ID] normalized_hkid = normalize_hkid(hkid) - - # If this aiohomekit doesn't support this particular device, ignore it. - if not domain_supported(discovery_info.name): - return self.async_abort(reason="ignored_model") - - model = properties["md"] - name = domain_to_name(discovery_info.name) + upper_case_hkid = hkid.upper() status_flags = int(properties["sf"]) - category = Categories(int(properties.get("ci", 0))) paired = not status_flags & 0x01 # Set unique-id and error out if it's already configured @@ -265,23 +259,29 @@ async def async_step_zeroconf( "AccessoryIP": discovery_info.host, "AccessoryPort": discovery_info.port, } - # If the device is already paired and known to us we should monitor c# # (config_num) for changes. If it changes, we check for new entities - if paired and hkid in self.hass.data.get(KNOWN_DEVICES, {}): + if paired and upper_case_hkid in self.hass.data.get(KNOWN_DEVICES, {}): if existing_entry: self.hass.config_entries.async_update_entry( existing_entry, data={**existing_entry.data, **updated_ip_port} ) return self.async_abort(reason="already_configured") - _LOGGER.debug("Discovered device %s (%s - %s)", name, model, hkid) + # If this aiohomekit doesn't support this particular device, ignore it. + if not domain_supported(discovery_info.name): + return self.async_abort(reason="ignored_model") + + model = properties["md"] + name = domain_to_name(discovery_info.name) + _LOGGER.debug("Discovered device %s (%s - %s)", name, model, upper_case_hkid) # Device isn't paired with us or anyone else. # But we have a 'complete' config entry for it - that is probably # invalid. Remove it automatically. - existing = find_existing_host(self.hass, hkid) - if not paired and existing: + if not paired and ( + existing := find_existing_config_entry(self.hass, upper_case_hkid) + ): if self.controller is None: await self._async_setup_controller() @@ -348,13 +348,13 @@ async def async_step_zeroconf( # If this is a HomeKit bridge/accessory exported # by *this* HA instance ignore it. - if await self._hkid_is_homekit(hkid): + if self._hkid_is_homekit(hkid): return self.async_abort(reason="ignored_model") self.name = name self.model = model - self.category = category - self.hkid = hkid + self.category = Categories(int(properties.get("ci", 0))) + self.hkid = normalized_hkid # We want to show the pairing form - but don't call async_step_pair # directly as it has side effects (will ask the device to show a diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index 469bd8618d2038..3412e41aa175ce 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -1180,3 +1180,80 @@ async def test_bluetooth_valid_device_discovery_unpaired( assert result3["data"] == {} assert storage.get_map("00:00:00:00:00:00") is not None + + +async def test_discovery_updates_ip_when_config_entry_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when config entry set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + connection_mock = AsyncMock() + hass.data[KNOWN_DEVICES] = {"AA:BB:CC:DD:EE:FF": connection_mock} + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port + + +async def test_discovery_updates_ip_config_entry_not_set_up( + hass: HomeAssistant, controller +) -> None: + """Already configured updates ip when the config entry is not set up.""" + entry = MockConfigEntry( + domain="homekit_controller", + data={ + "AccessoryIP": "4.4.4.4", + "AccessoryPort": 66, + "AccessoryPairingID": "AA:BB:CC:DD:EE:FF", + }, + unique_id="aa:bb:cc:dd:ee:ff", + ) + entry.add_to_hass(hass) + + AsyncMock() + + device = setup_mock_accessory(controller) + discovery_info = get_device_discovery_info(device) + + # Set device as already paired + discovery_info.properties["sf"] = 0x00 + discovery_info.properties[zeroconf.ATTR_PROPERTIES_ID] = "Aa:bB:cC:dD:eE:fF" + + # Device is discovered + result = await hass.config_entries.flow.async_init( + "homekit_controller", + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + await hass.async_block_till_done() + + assert entry.data["AccessoryIP"] == discovery_info.host + assert entry.data["AccessoryPort"] == discovery_info.port From fbcc5318c575d4cbabb1e96472636503e0fc8a75 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Wed, 20 Sep 2023 18:09:12 +0200 Subject: [PATCH 618/640] Move attributes to be excluded from recording to entity classes (#100239) Co-authored-by: J. Nick Koston --- .../components/automation/__init__.py | 3 ++ .../components/automation/recorder.py | 12 ----- .../components/recorder/db_schema.py | 2 + homeassistant/core.py | 5 ++ homeassistant/helpers/entity.py | 46 +++++++++++++++++-- 5 files changed, 52 insertions(+), 16 deletions(-) delete mode 100644 homeassistant/components/automation/recorder.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index f4db7831235fb1..fd6a70cce46e58 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -314,6 +314,9 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class BaseAutomationEntity(ToggleEntity, ABC): """Base class for automation entities.""" + _entity_component_unrecorded_attributes = frozenset( + (ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID) + ) raw_config: ConfigType | None @property diff --git a/homeassistant/components/automation/recorder.py b/homeassistant/components/automation/recorder.py deleted file mode 100644 index 3083d271d1ffb6..00000000000000 --- a/homeassistant/components/automation/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE, CONF_ID - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, CONF_ID} diff --git a/homeassistant/components/recorder/db_schema.py b/homeassistant/components/recorder/db_schema.py index e25c6d6dd5fbc3..e992a683cb13cd 100644 --- a/homeassistant/components/recorder/db_schema.py +++ b/homeassistant/components/recorder/db_schema.py @@ -576,6 +576,8 @@ def shared_attrs_bytes_from_event( integration_attrs := exclude_attrs_by_domain.get(entity_info["domain"]) ): exclude_attrs |= integration_attrs + if state_info := state.state_info: + exclude_attrs |= state_info["unrecorded_attributes"] encoder = json_bytes_strip_null if dialect == PSQL_DIALECT else json_bytes bytes_result = encoder( {k: v for k, v in state.attributes.items() if k not in exclude_attrs} diff --git a/homeassistant/core.py b/homeassistant/core.py index a43fa1997c6812..a50d43c1344f80 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -95,6 +95,7 @@ from .auth import AuthManager from .components.http import ApiConfig, HomeAssistantHTTP from .config_entries import ConfigEntries + from .helpers.entity import StateInfo STAGE_1_SHUTDOWN_TIMEOUT = 100 @@ -1249,6 +1250,7 @@ def __init__( last_updated: datetime.datetime | None = None, context: Context | None = None, validate_entity_id: bool | None = True, + state_info: StateInfo | None = None, ) -> None: """Initialize a new state.""" state = str(state) @@ -1267,6 +1269,7 @@ def __init__( self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated self.context = context or Context() + self.state_info = state_info self.domain, self.object_id = split_entity_id(self.entity_id) self._as_dict: ReadOnlyDict[str, Collection[Any]] | None = None @@ -1637,6 +1640,7 @@ def async_set( attributes: Mapping[str, Any] | None = None, force_update: bool = False, context: Context | None = None, + state_info: StateInfo | None = None, ) -> None: """Set the state of an entity, add entity if it does not exist. @@ -1688,6 +1692,7 @@ def async_set( now, context, old_state is None, + state_info, ) if old_state is not None: old_state.expire() diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index 5ed16408388545..9b16b0c24fdd24 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -201,6 +201,12 @@ class EntityInfo(TypedDict): config_entry: NotRequired[str] +class StateInfo(TypedDict): + """State info.""" + + unrecorded_attributes: frozenset[str] + + class EntityPlatformState(Enum): """The platform state of an entity.""" @@ -297,6 +303,22 @@ class Entity(ABC): # If entity is added to an entity platform _platform_state = EntityPlatformState.NOT_ADDED + # Attributes to exclude from recording, only set by base components, e.g. light + _entity_component_unrecorded_attributes: frozenset[str] = frozenset() + # Additional integration specific attributes to exclude from recording, set by + # platforms, e.g. a derived class in hue.light + _unrecorded_attributes: frozenset[str] = frozenset() + # Union of _entity_component_unrecorded_attributes and _unrecorded_attributes, + # set automatically by __init_subclass__ + __combined_unrecorded_attributes: frozenset[str] = ( + _entity_component_unrecorded_attributes | _unrecorded_attributes + ) + + # StateInfo. Set by EntityPlatform by calling async_internal_added_to_hass + # While not purely typed, it makes typehinting more useful for us + # and removes the need for constant None checks or asserts. + _state_info: StateInfo = None # type: ignore[assignment] + # Entity Properties _attr_assumed_state: bool = False _attr_attribution: str | None = None @@ -321,6 +343,13 @@ class Entity(ABC): _attr_unique_id: str | None = None _attr_unit_of_measurement: str | None + def __init_subclass__(cls, **kwargs: Any) -> None: + """Initialize an Entity subclass.""" + super().__init_subclass__(**kwargs) + cls.__combined_unrecorded_attributes = ( + cls._entity_component_unrecorded_attributes | cls._unrecorded_attributes + ) + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -875,7 +904,12 @@ def _async_write_ha_state(self) -> None: try: hass.states.async_set( - entity_id, state, attr, self.force_update, self._context + entity_id, + state, + attr, + self.force_update, + self._context, + self._state_info, ) except InvalidStateError: _LOGGER.exception("Failed to set state, fall back to %s", STATE_UNKNOWN) @@ -1081,15 +1115,19 @@ async def async_internal_added_to_hass(self) -> None: Not to be extended by integrations. """ - info: EntityInfo = { + entity_info: EntityInfo = { "domain": self.platform.platform_name, "custom_component": "custom_components" in type(self).__module__, } if self.platform.config_entry: - info["config_entry"] = self.platform.config_entry.entry_id + entity_info["config_entry"] = self.platform.config_entry.entry_id - entity_sources(self.hass)[self.entity_id] = info + entity_sources(self.hass)[self.entity_id] = entity_info + + self._state_info = { + "unrecorded_attributes": self.__combined_unrecorded_attributes + } if self.registry_entry is not None: # This is an assert as it should never happen, but helps in tests From 1f0c9a48d2860ce90b5b4ba28b84cb92a6c29af7 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 18:35:55 +0200 Subject: [PATCH 619/640] Update doorbird zeroconf checks to use stdlib ipaddress methods (#100623) --- homeassistant/components/doorbird/config_flow.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/doorbird/config_flow.py b/homeassistant/components/doorbird/config_flow.py index 56a02f490421be..983e56e64da5ab 100644 --- a/homeassistant/components/doorbird/config_flow.py +++ b/homeassistant/components/doorbird/config_flow.py @@ -2,7 +2,6 @@ from __future__ import annotations from http import HTTPStatus -from ipaddress import ip_address import logging from typing import Any @@ -15,7 +14,6 @@ from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.data_entry_flow import FlowResult -from homeassistant.util.network import is_ipv4_address, is_link_local from .const import CONF_EVENTS, DOMAIN, DOORBIRD_OUI from .util import get_mac_address_from_door_station_info @@ -106,16 +104,16 @@ async def async_step_zeroconf( ) -> FlowResult: """Prepare configuration for a discovered doorbird device.""" macaddress = discovery_info.properties["macaddress"] - host = discovery_info.host if macaddress[:6] != DOORBIRD_OUI: return self.async_abort(reason="not_doorbird_device") - if is_link_local(ip_address(host)): + if discovery_info.ip_address.is_link_local: return self.async_abort(reason="link_local_address") - if not is_ipv4_address(host): + if discovery_info.ip_address.version != 4: return self.async_abort(reason="not_ipv4_address") await self.async_set_unique_id(macaddress) + host = discovery_info.host self._abort_if_unique_id_configured(updates={CONF_HOST: host}) self._async_abort_entries_match({CONF_HOST: host}) From a03ad87cfb39874eebd215f4df44ab4a61b0ac91 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 20 Sep 2023 18:43:15 +0200 Subject: [PATCH 620/640] Avoid ConfigEntry lookups in hass.config_entries.async_entries for domain index (#100598) --- homeassistant/config_entries.py | 17 ++++++++--------- tests/common.py | 12 +++++------- 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index f4e61bfffbd6b5..ed5ba79c1b42af 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -1047,7 +1047,7 @@ def __init__(self, hass: HomeAssistant, hass_config: ConfigType) -> None: self.options = OptionsFlowManager(hass) self._hass_config = hass_config self._entries: dict[str, ConfigEntry] = {} - self._domain_index: dict[str, list[str]] = {} + self._domain_index: dict[str, list[ConfigEntry]] = {} self._store = storage.Store[dict[str, list[dict[str, Any]]]]( hass, STORAGE_VERSION, STORAGE_KEY ) @@ -1077,9 +1077,7 @@ def async_entries(self, domain: str | None = None) -> list[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries.values()) - return [ - self._entries[entry_id] for entry_id in self._domain_index.get(domain, []) - ] + return list(self._domain_index.get(domain, [])) async def async_add(self, entry: ConfigEntry) -> None: """Add and setup an entry.""" @@ -1088,7 +1086,7 @@ async def async_add(self, entry: ConfigEntry) -> None: f"An entry with the id {entry.entry_id} already exists." ) self._entries[entry.entry_id] = entry - self._domain_index.setdefault(entry.domain, []).append(entry.entry_id) + self._domain_index.setdefault(entry.domain, []).append(entry) self._async_dispatch(ConfigEntryChange.ADDED, entry) await self.async_setup(entry.entry_id) self._async_schedule_save() @@ -1106,7 +1104,7 @@ async def async_remove(self, entry_id: str) -> dict[str, Any]: await entry.async_remove(self.hass) del self._entries[entry.entry_id] - self._domain_index[entry.domain].remove(entry.entry_id) + self._domain_index[entry.domain].remove(entry) if not self._domain_index[entry.domain]: del self._domain_index[entry.domain] self._async_schedule_save() @@ -1173,7 +1171,7 @@ async def async_initialize(self) -> None: return entries = {} - domain_index: dict[str, list[str]] = {} + domain_index: dict[str, list[ConfigEntry]] = {} for entry in config["entries"]: pref_disable_new_entities = entry.get("pref_disable_new_entities") @@ -1188,7 +1186,7 @@ async def async_initialize(self) -> None: domain = entry["domain"] entry_id = entry["entry_id"] - entries[entry_id] = ConfigEntry( + config_entry = ConfigEntry( version=entry["version"], domain=domain, entry_id=entry_id, @@ -1207,7 +1205,8 @@ async def async_initialize(self) -> None: pref_disable_new_entities=pref_disable_new_entities, pref_disable_polling=entry.get("pref_disable_polling"), ) - domain_index.setdefault(domain, []).append(entry_id) + entries[entry_id] = config_entry + domain_index.setdefault(domain, []).append(config_entry) self._domain_index = domain_index self._entries = entries diff --git a/tests/common.py b/tests/common.py index 48bb38383c7c9d..af18640843d6ff 100644 --- a/tests/common.py +++ b/tests/common.py @@ -891,7 +891,7 @@ def __init__( unique_id=None, disabled_by=None, reason=None, - ): + ) -> None: """Initialize a mock config entry.""" kwargs = { "entry_id": entry_id or uuid_util.random_uuid_hex(), @@ -913,17 +913,15 @@ def __init__( if reason is not None: self.reason = reason - def add_to_hass(self, hass): + def add_to_hass(self, hass: HomeAssistant) -> None: """Test helper to add entry to hass.""" hass.config_entries._entries[self.entry_id] = self - hass.config_entries._domain_index.setdefault(self.domain, []).append( - self.entry_id - ) + hass.config_entries._domain_index.setdefault(self.domain, []).append(self) - def add_to_manager(self, manager): + def add_to_manager(self, manager: config_entries.ConfigEntries) -> None: """Test helper to add entry to entry manager.""" manager._entries[self.entry_id] = self - manager._domain_index.setdefault(self.domain, []).append(self.entry_id) + manager._domain_index.setdefault(self.domain, []).append(self) def patch_yaml_files(files_dict, endswith=True): From 6752af8f27516be52600c1782f160d724760e869 Mon Sep 17 00:00:00 2001 From: Andrei Demian Date: Wed, 20 Sep 2023 20:44:11 +0300 Subject: [PATCH 621/640] Bump ismartgate to 5.0.1 (#100636) --- homeassistant/components/gogogate2/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/gogogate2/manifest.json b/homeassistant/components/gogogate2/manifest.json index faebcf7e35328a..40633537ddf44a 100644 --- a/homeassistant/components/gogogate2/manifest.json +++ b/homeassistant/components/gogogate2/manifest.json @@ -14,5 +14,5 @@ }, "iot_class": "local_polling", "loggers": ["ismartgate"], - "requirements": ["ismartgate==5.0.0"] + "requirements": ["ismartgate==5.0.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8eb3b3240646f9..9d2d522595d07b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1076,7 +1076,7 @@ intellifire4py==2.2.2 iperf3==0.1.11 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d6d9bb9242e354..738601f3ebf88f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -841,7 +841,7 @@ insteon-frontend-home-assistant==0.4.0 intellifire4py==2.2.2 # homeassistant.components.gogogate2 -ismartgate==5.0.0 +ismartgate==5.0.1 # homeassistant.components.file_upload janus==1.0.0 From 05254547380e95a902e41badc2c6159f250e11e1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 20 Sep 2023 20:47:38 +0200 Subject: [PATCH 622/640] Bump tibdex/github-app-token from 2.0.0 to 2.1.0 (#100632) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/stale.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 212cd0498b6bb2..c91117cb02d352 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -42,7 +42,7 @@ jobs: id: token # Pinned to a specific version of the action for security reasons # v1.7.0 - uses: tibdex/github-app-token@0914d50df753bbc42180d982a6550f195390069f + uses: tibdex/github-app-token@3beb63f4bd073e61482598c45c71c1019b59b73a with: app_id: ${{ secrets.ISSUE_TRIAGE_APP_ID }} private_key: ${{ secrets.ISSUE_TRIAGE_APP_PEM }} From ed3cdca454c35ac85b8bcdb7d2ae744b0964808d Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Wed, 20 Sep 2023 16:02:00 -0400 Subject: [PATCH 623/640] Bump python-roborock to 0.34.1 (#100652) bump to 34.1 --- homeassistant/components/roborock/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/roborock/manifest.json b/homeassistant/components/roborock/manifest.json index 81bbd07d904c88..dfd5a9ee1c7245 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.0"] + "requirements": ["python-roborock==0.34.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 9d2d522595d07b..609b55e42f5a44 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2166,7 +2166,7 @@ python-qbittorrent==0.4.3 python-ripple-api==0.0.3 # homeassistant.components.roborock -python-roborock==0.34.0 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 738601f3ebf88f..4515978674743a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1598,7 +1598,7 @@ python-picnic-api==1.1.0 python-qbittorrent==0.4.3 # homeassistant.components.roborock -python-roborock==0.34.0 +python-roborock==0.34.1 # homeassistant.components.smarttub python-smarttub==0.0.33 From 5c1a3998aeaa4b6c4cf524b11f561f6ce27f7674 Mon Sep 17 00:00:00 2001 From: anonion Date: Wed, 20 Sep 2023 21:59:05 -0700 Subject: [PATCH 624/640] Add Enmax virtual integration to Opower (#100503) * add enmax virtual integration supported by opower * update integrations.json --- homeassistant/components/enmax/__init__.py | 1 + homeassistant/components/enmax/manifest.json | 6 ++++++ homeassistant/generated/integrations.json | 5 +++++ 3 files changed, 12 insertions(+) create mode 100644 homeassistant/components/enmax/__init__.py create mode 100644 homeassistant/components/enmax/manifest.json diff --git a/homeassistant/components/enmax/__init__.py b/homeassistant/components/enmax/__init__.py new file mode 100644 index 00000000000000..21ca8ab1c58afb --- /dev/null +++ b/homeassistant/components/enmax/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Enmax Energy.""" diff --git a/homeassistant/components/enmax/manifest.json b/homeassistant/components/enmax/manifest.json new file mode 100644 index 00000000000000..2c2be41382462a --- /dev/null +++ b/homeassistant/components/enmax/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "enmax", + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 966cf186346e15..8c7defb696925f 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1481,6 +1481,11 @@ "config_flow": false, "iot_class": "local_polling" }, + "enmax": { + "name": "Enmax Energy", + "integration_type": "virtual", + "supported_by": "opower" + }, "enocean": { "name": "EnOcean", "integration_type": "hub", From 9f56aec2676f24ba23487aa18b9c4966d98e9afe Mon Sep 17 00:00:00 2001 From: Raman Gupta <7243222+raman325@users.noreply.github.com> Date: Thu, 21 Sep 2023 02:13:48 -0400 Subject: [PATCH 625/640] Bump zwave-js-server-python to 0.51.3 (#100665) --- 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 4ea46099f14154..cfb2c239d8ef3c 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.51.2"], + "requirements": ["pyserial==3.5", "zwave-js-server-python==0.51.3"], "usb": [ { "vid": "0658", diff --git a/requirements_all.txt b/requirements_all.txt index 609b55e42f5a44..e7cb02e348f915 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2806,7 +2806,7 @@ zigpy==0.57.1 zm-py==0.5.2 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.2 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4515978674743a..1fcd66250317e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2076,7 +2076,7 @@ zigpy-znp==0.11.4 zigpy==0.57.1 # homeassistant.components.zwave_js -zwave-js-server-python==0.51.2 +zwave-js-server-python==0.51.3 # homeassistant.components.zwave_me zwave-me-ws==0.4.3 From f2fc62138aa3714ced71bd758ccc052fa02d100d Mon Sep 17 00:00:00 2001 From: elmurato <1382097+elmurato@users.noreply.github.com> Date: Thu, 21 Sep 2023 08:40:07 +0200 Subject: [PATCH 626/640] Clean-up Minecraft Server constants (#100666) --- .../minecraft_server/binary_sensor.py | 6 +++- .../minecraft_server/config_flow.py | 4 ++- .../components/minecraft_server/const.py | 25 ------------- .../minecraft_server/coordinator.py | 5 +-- .../components/minecraft_server/entity.py | 4 ++- .../components/minecraft_server/helpers.py | 2 +- .../components/minecraft_server/sensor.py | 36 +++++++++---------- 7 files changed, 33 insertions(+), 49 deletions(-) diff --git a/homeassistant/components/minecraft_server/binary_sensor.py b/homeassistant/components/minecraft_server/binary_sensor.py index 0446e0a2d7cf6b..e89fce2d7d5a8f 100644 --- a/homeassistant/components/minecraft_server/binary_sensor.py +++ b/homeassistant/components/minecraft_server/binary_sensor.py @@ -10,10 +10,14 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback -from .const import DOMAIN, ICON_STATUS, KEY_STATUS +from .const import DOMAIN from .coordinator import MinecraftServerCoordinator from .entity import MinecraftServerEntity +ICON_STATUS = "mdi:lan" + +KEY_STATUS = "status" + @dataclass class MinecraftServerBinarySensorEntityDescription(BinarySensorEntityDescription): diff --git a/homeassistant/components/minecraft_server/config_flow.py b/homeassistant/components/minecraft_server/config_flow.py index beacfde5b8e193..f4b4212bc64bef 100644 --- a/homeassistant/components/minecraft_server/config_flow.py +++ b/homeassistant/components/minecraft_server/config_flow.py @@ -10,7 +10,9 @@ from homeassistant.data_entry_flow import FlowResult from . import helpers -from .const import DEFAULT_HOST, DEFAULT_NAME, DEFAULT_PORT, DOMAIN +from .const import DEFAULT_NAME, DEFAULT_PORT, DOMAIN + +DEFAULT_HOST = "localhost:25565" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/minecraft_server/const.py b/homeassistant/components/minecraft_server/const.py index ea510c467a1443..9f14f429a12c1b 100644 --- a/homeassistant/components/minecraft_server/const.py +++ b/homeassistant/components/minecraft_server/const.py @@ -1,34 +1,9 @@ """Constants for the Minecraft Server integration.""" -ATTR_PLAYERS_LIST = "players_list" - -DEFAULT_HOST = "localhost:25565" DEFAULT_NAME = "Minecraft Server" DEFAULT_PORT = 25565 DOMAIN = "minecraft_server" -ICON_LATENCY = "mdi:signal" -ICON_PLAYERS_MAX = "mdi:account-multiple" -ICON_PLAYERS_ONLINE = "mdi:account-multiple" -ICON_PROTOCOL_VERSION = "mdi:numeric" -ICON_STATUS = "mdi:lan" -ICON_VERSION = "mdi:numeric" -ICON_MOTD = "mdi:minecraft" - KEY_LATENCY = "latency" -KEY_PLAYERS_MAX = "players_max" -KEY_PLAYERS_ONLINE = "players_online" -KEY_PROTOCOL_VERSION = "protocol_version" -KEY_STATUS = "status" -KEY_VERSION = "version" KEY_MOTD = "motd" - -MANUFACTURER = "Mojang AB" - -SCAN_INTERVAL = 60 - -SRV_RECORD_PREFIX = "_minecraft._tcp" - -UNIT_PLAYERS_MAX = "players" -UNIT_PLAYERS_ONLINE = "players" diff --git a/homeassistant/components/minecraft_server/coordinator.py b/homeassistant/components/minecraft_server/coordinator.py index 6965759e734dcd..178c12772c6686 100644 --- a/homeassistant/components/minecraft_server/coordinator.py +++ b/homeassistant/components/minecraft_server/coordinator.py @@ -14,7 +14,8 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from . import helpers -from .const import SCAN_INTERVAL + +SCAN_INTERVAL = timedelta(seconds=60) _LOGGER = logging.getLogger(__name__) @@ -45,7 +46,7 @@ def __init__( hass=hass, name=config_data[CONF_NAME], logger=_LOGGER, - update_interval=timedelta(seconds=SCAN_INTERVAL), + update_interval=SCAN_INTERVAL, ) # Server data diff --git a/homeassistant/components/minecraft_server/entity.py b/homeassistant/components/minecraft_server/entity.py index e7e91c7be86c94..9bac71e00005ba 100644 --- a/homeassistant/components/minecraft_server/entity.py +++ b/homeassistant/components/minecraft_server/entity.py @@ -3,9 +3,11 @@ from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import DOMAIN, MANUFACTURER +from .const import DOMAIN from .coordinator import MinecraftServerCoordinator +MANUFACTURER = "Mojang Studios" + class MinecraftServerEntity(CoordinatorEntity[MinecraftServerCoordinator]): """Representation of a Minecraft Server base entity.""" diff --git a/homeassistant/components/minecraft_server/helpers.py b/homeassistant/components/minecraft_server/helpers.py index ac9ec52f679014..f5991620c68515 100644 --- a/homeassistant/components/minecraft_server/helpers.py +++ b/homeassistant/components/minecraft_server/helpers.py @@ -6,7 +6,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT -from .const import SRV_RECORD_PREFIX +SRV_RECORD_PREFIX = "_minecraft._tcp" _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/minecraft_server/sensor.py b/homeassistant/components/minecraft_server/sensor.py index 27749e5b60f78c..efe534e0f92002 100644 --- a/homeassistant/components/minecraft_server/sensor.py +++ b/homeassistant/components/minecraft_server/sensor.py @@ -12,27 +12,27 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import StateType -from .const import ( - ATTR_PLAYERS_LIST, - DOMAIN, - ICON_LATENCY, - ICON_MOTD, - ICON_PLAYERS_MAX, - ICON_PLAYERS_ONLINE, - ICON_PROTOCOL_VERSION, - ICON_VERSION, - KEY_LATENCY, - KEY_MOTD, - KEY_PLAYERS_MAX, - KEY_PLAYERS_ONLINE, - KEY_PROTOCOL_VERSION, - KEY_VERSION, - UNIT_PLAYERS_MAX, - UNIT_PLAYERS_ONLINE, -) +from .const import DOMAIN, KEY_LATENCY, KEY_MOTD from .coordinator import MinecraftServerCoordinator, MinecraftServerData from .entity import MinecraftServerEntity +ATTR_PLAYERS_LIST = "players_list" + +ICON_LATENCY = "mdi:signal" +ICON_PLAYERS_MAX = "mdi:account-multiple" +ICON_PLAYERS_ONLINE = "mdi:account-multiple" +ICON_PROTOCOL_VERSION = "mdi:numeric" +ICON_VERSION = "mdi:numeric" +ICON_MOTD = "mdi:minecraft" + +KEY_PLAYERS_MAX = "players_max" +KEY_PLAYERS_ONLINE = "players_online" +KEY_PROTOCOL_VERSION = "protocol_version" +KEY_VERSION = "version" + +UNIT_PLAYERS_MAX = "players" +UNIT_PLAYERS_ONLINE = "players" + @dataclass class MinecraftServerEntityDescriptionMixin: From 59daceafd2c70256e01382542cd2bed70be469df Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Thu, 21 Sep 2023 09:48:41 +0200 Subject: [PATCH 627/640] Avoid calling extract_stack in system_log since it does blocking I/O (#100455) --- .../components/system_log/__init__.py | 77 +++++++++++++------ homeassistant/components/zha/core/gateway.py | 18 ++--- tests/components/system_log/test_init.py | 56 ++++++++++---- 3 files changed, 104 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index ab271ec676c25c..fab2b7ee2914ae 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -4,6 +4,7 @@ from collections import OrderedDict, deque import logging import re +import sys import traceback from typing import Any, cast @@ -59,31 +60,65 @@ def _figure_out_source( - record: logging.LogRecord, call_stack: list[tuple[str, int]], paths_re: re.Pattern + record: logging.LogRecord, paths_re: re.Pattern ) -> tuple[str, int]: + """Figure out where a log message came from.""" # If a stack trace exists, extract file names from the entire call stack. # The other case is when a regular "log" is made (without an attached # exception). In that case, just use the file where the log was made from. if record.exc_info: stack = [(x[0], x[1]) for x in traceback.extract_tb(record.exc_info[2])] - else: - index = -1 - for i, frame in enumerate(call_stack): - if frame[0] == record.pathname: - index = i + for i, (filename, _) in enumerate(stack): + # Slice the stack to the first frame that matches + # the record pathname. + if filename == record.pathname: + stack = stack[0 : i + 1] break - if index == -1: - # For some reason we couldn't find pathname in the stack. - stack = [(record.pathname, record.lineno)] - else: - stack = call_stack[0 : index + 1] - - # Iterate through the stack call (in reverse) and find the last call from - # a file in Home Assistant. Try to figure out where error happened. - for pathname in reversed(stack): - # Try to match with a file within Home Assistant - if match := paths_re.match(pathname[0]): - return (cast(str, match.group(1)), pathname[1]) + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + for path, line_number in reversed(stack): + # Try to match with a file within Home Assistant + if match := paths_re.match(path): + return (cast(str, match.group(1)), line_number) + else: + # + # We need to figure out where the log call came from if we + # don't have an exception. + # + # We do this by walking up the stack until we find the first + # frame match the record pathname so the code below + # can be used to reverse the remaining stack frames + # and find the first one that is from a file within Home Assistant. + # + # We do not call traceback.extract_stack() because it is + # it makes many stat() syscalls calls which do blocking I/O, + # and since this code is running in the event loop, we need to avoid + # blocking I/O. + + frame = sys._getframe(4) # pylint: disable=protected-access + # + # We use _getframe with 4 to skip the following frames: + # + # Jump 2 frames up to get to the actual caller + # since we are in a function, and always called from another function + # that are never the original source of the log message. + # + # Next try to skip any frames that are from the logging module + # We know that the logger module typically has 5 frames itself + # but it may change in the future so we are conservative and + # only skip 2. + # + # _getframe is cpython only but we are already using cpython specific + # code everywhere in HA so it's fine as its unlikely we will ever + # support other python implementations. + # + # Iterate through the stack call (in reverse) and find the last call from + # a file in Home Assistant. Try to figure out where error happened. + while back := frame.f_back: + if match := paths_re.match(frame.f_code.co_filename): + return (cast(str, match.group(1)), frame.f_lineno) + frame = back + # Ok, we don't know what this is return (record.pathname, record.lineno) @@ -217,11 +252,7 @@ def emit(self, record: logging.LogRecord) -> None: default upper limit is set to 50 (older entries are discarded) but can be changed if needed. """ - stack = [] - if not record.exc_info: - stack = [(f[0], f[1]) for f in traceback.extract_stack()] - - entry = LogEntry(record, _figure_out_source(record, stack, self.paths_re)) + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) self.records.add_entry(entry) if self.fire_event: self.hass.bus.fire(EVENT_SYSTEM_LOG, entry.to_dict()) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 5fe84005d7a833..c5d04dda9611e8 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -10,7 +10,6 @@ import logging import re import time -import traceback from typing import TYPE_CHECKING, Any, NamedTuple from zigpy.application import ControllerApplication @@ -814,21 +813,20 @@ def __init__(self, hass: HomeAssistant, gateway: ZHAGateway) -> None: super().__init__() self.hass = hass self.gateway = gateway - - def emit(self, record: LogRecord) -> None: - """Relay log message via dispatcher.""" - stack = [] - if record.levelno >= logging.WARN and not record.exc_info: - stack = [f for f, _, _, _ in traceback.extract_stack()] - hass_path: str = HOMEASSISTANT_PATH[0] config_dir = self.hass.config.config_dir - paths_re = re.compile( + self.paths_re = re.compile( r"(?:{})/(.*)".format( "|".join([re.escape(x) for x in (hass_path, config_dir)]) ) ) - entry = LogEntry(record, _figure_out_source(record, stack, paths_re)) + + def emit(self, record: LogRecord) -> None: + """Relay log message via dispatcher.""" + if record.levelno >= logging.WARN: + entry = LogEntry(record, _figure_out_source(record, self.paths_re)) + else: + entry = LogEntry(record, (record.pathname, record.lineno)) async_dispatcher_send( self.hass, ZHA_GW_MSG, diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index bd861ac7668325..1357d9e5e9e081 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -4,6 +4,7 @@ import asyncio from collections.abc import Awaitable import logging +import re import traceback from typing import Any from unittest.mock import MagicMock, patch @@ -87,11 +88,6 @@ def handle(self, record: logging.LogRecord) -> None: self.watch_event.set() -def get_frame(name): - """Get log stack frame.""" - return (name, 5, None, None) - - async def async_setup_system_log(hass, config) -> WatchLogErrorHandler: """Set up the system_log component.""" WatchLogErrorHandler.instances = [] @@ -362,21 +358,28 @@ async def test_unknown_path( assert log["source"] == ["unknown_path", 0] +def get_frame(path: str, previous_frame: MagicMock | None) -> MagicMock: + """Get log stack frame.""" + return MagicMock( + f_back=previous_frame, + f_code=MagicMock(co_filename=path), + f_lineno=5, + ) + + async def async_log_error_from_test_path(hass, path, watcher): """Log error while mocking the path.""" call_path = "internal_path.py" + main_frame = get_frame("main_path/main.py", None) + path_frame = get_frame(path, main_frame) + call_path_frame = get_frame(call_path, path_frame) + logger_frame = get_frame("venv_path/logging/log.py", call_path_frame) + with patch.object( _LOGGER, "findCaller", MagicMock(return_value=(call_path, 0, None, None)) ), patch( - "traceback.extract_stack", - MagicMock( - return_value=[ - get_frame("main_path/main.py"), - get_frame(path), - get_frame(call_path), - get_frame("venv_path/logging/log.py"), - ] - ), + "homeassistant.components.system_log.sys._getframe", + return_value=logger_frame, ): wait_empty = watcher.add_watcher("error message") _LOGGER.error("error message") @@ -441,3 +444,28 @@ def __repr__(self): log = find_log(await get_error_log(hass_ws_client), "ERROR") assert log is not None assert_log(log, "", "Bad logger message: repr error", "ERROR") + + +async def test__figure_out_source(hass: HomeAssistant) -> None: + """Test that source is figured out correctly. + + We have to test this directly for exception tracebacks since + we cannot generate a trackback from a Home Assistant component + in a test because the test is not a component. + """ + try: + raise ValueError("test") + except ValueError as ex: + exc_info = (type(ex), ex, ex.__traceback__) + mock_record = MagicMock( + pathname="should not hit", + lineno=5, + exc_info=exc_info, + ) + regex_str = f"({__file__})" + file, line_no = system_log._figure_out_source( + mock_record, + re.compile(regex_str), + ) + assert file == __file__ + assert line_no != 5 From 715d8dcb9871e295543b47473855a9e5561aebc5 Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Sep 2023 10:44:32 +0200 Subject: [PATCH 628/640] Add test to london underground (#100562) Co-authored-by: Robert Resch --- CODEOWNERS | 1 + requirements_test_all.txt | 3 + .../components/london_underground/__init__.py | 1 + .../fixtures/line_status.json | 514 ++++++++++++++++++ .../london_underground/test_sensor.py | 36 ++ 5 files changed, 555 insertions(+) create mode 100644 tests/components/london_underground/__init__.py create mode 100644 tests/components/london_underground/fixtures/line_status.json create mode 100644 tests/components/london_underground/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index fe6aba2e5bb1c3..f3ff40246776b6 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -712,6 +712,7 @@ build.json @home-assistant/supervisor /homeassistant/components/logi_circle/ @evanjd /tests/components/logi_circle/ @evanjd /homeassistant/components/london_underground/ @jpbede +/tests/components/london_underground/ @jpbede /homeassistant/components/lookin/ @ANMalko @bdraco /tests/components/lookin/ @ANMalko @bdraco /homeassistant/components/loqed/ @mikewoudenberg diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1fcd66250317e1..0f6f5d3880619a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -897,6 +897,9 @@ life360==6.0.0 # homeassistant.components.logi_circle logi-circle==0.2.3 +# homeassistant.components.london_underground +london-tube-status==0.5 + # homeassistant.components.loqed loqedAPI==2.1.7 diff --git a/tests/components/london_underground/__init__.py b/tests/components/london_underground/__init__.py new file mode 100644 index 00000000000000..5de380bde1c47b --- /dev/null +++ b/tests/components/london_underground/__init__.py @@ -0,0 +1 @@ +"""Tests for the london_underground component.""" diff --git a/tests/components/london_underground/fixtures/line_status.json b/tests/components/london_underground/fixtures/line_status.json new file mode 100644 index 00000000000000..a014fc168c6f80 --- /dev/null +++ b/tests/components/london_underground/fixtures/line_status.json @@ -0,0 +1,514 @@ +[ + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "bakerloo", + "name": "Bakerloo", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Bakerloo&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "central", + "name": "Central", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Central&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Central&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "circle", + "name": "Circle", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Circle&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "district", + "name": "District", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "district", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T18:25:36Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "District Line: No service between Turnham Green and Ealing Broadway while we remove a tree from the track at Ealing Common. Valid tickets will be accepted on local buses. GOOD SERVICE on the rest of the line ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=District&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "dlr", + "name": "DLR", + "modeName": "dlr", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=DLR&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "elizabeth", + "name": "Elizabeth line", + "modeName": "elizabeth-line", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Elizabeth line&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "hammersmith-city", + "name": "Hammersmith & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Hammersmith & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "jubilee", + "name": "Jubilee", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Jubilee&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "london-overground", + "name": "London Overground", + "modeName": "overground", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=London Overground&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "metropolitan", + "name": "Metropolitan", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Metropolitan&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "northern", + "name": "Northern", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Northern&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Northern&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "piccadilly", + "name": "Piccadilly", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 6, + "statusSeverityDescription": "Severe Delays", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-19T00:29:00Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "severeDelays" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "lineId": "piccadilly", + "statusSeverity": 3, + "statusSeverityDescription": "Part Suspended", + "reason": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "created": "0001-01-01T00:00:00", + "validityPeriods": [ + { + "$type": "Tfl.Api.Presentation.Entities.ValidityPeriod, Tfl.Api.Presentation.Entities", + "fromDate": "2023-09-18T19:01:20Z", + "toDate": "2023-09-18T22:06:14Z", + "isNow": true + } + ], + "disruption": { + "$type": "Tfl.Api.Presentation.Entities.Disruption, Tfl.Api.Presentation.Entities", + "category": "RealTime", + "categoryDescription": "RealTime", + "description": "Piccadilly Line: No service between Acton Town and Uxbridge while we remove a tree from the track at Ealing Common. Severe delays between Acton Town and Cockfosters, eastbound only. GOOD SERVICE on the rest of the line. Valid tickets will be accepted on local buses, London Overground, Great Northern and South Western Railway. ", + "affectedRoutes": [], + "affectedStops": [], + "closureText": "partSuspended" + } + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Piccadilly&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "victoria", + "name": "Victoria", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.97Z", + "modified": "2023-09-11T10:28:16.97Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Regular" + }, + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Night", + "uri": "/Line/Route?ids=Victoria&serviceTypes=Night" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + }, + { + "$type": "Tfl.Api.Presentation.Entities.Line, Tfl.Api.Presentation.Entities", + "id": "waterloo-city", + "name": "Waterloo & City", + "modeName": "tube", + "disruptions": [], + "created": "2023-09-11T10:28:16.987Z", + "modified": "2023-09-11T10:28:16.987Z", + "lineStatuses": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineStatus, Tfl.Api.Presentation.Entities", + "id": 0, + "statusSeverity": 10, + "statusSeverityDescription": "Good Service", + "created": "0001-01-01T00:00:00", + "validityPeriods": [] + } + ], + "routeSections": [], + "serviceTypes": [ + { + "$type": "Tfl.Api.Presentation.Entities.LineServiceTypeInfo, Tfl.Api.Presentation.Entities", + "name": "Regular", + "uri": "/Line/Route?ids=Waterloo & City&serviceTypes=Regular" + } + ], + "crowding": { + "$type": "Tfl.Api.Presentation.Entities.Crowding, Tfl.Api.Presentation.Entities" + } + } +] diff --git a/tests/components/london_underground/test_sensor.py b/tests/components/london_underground/test_sensor.py new file mode 100644 index 00000000000000..4dda341279d7ad --- /dev/null +++ b/tests/components/london_underground/test_sensor.py @@ -0,0 +1,36 @@ +"""The tests for the london_underground platform.""" +from london_tube_status import API_URL + +from homeassistant.components.london_underground.const import CONF_LINE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +VALID_CONFIG = { + "sensor": {"platform": "london_underground", CONF_LINE: ["Metropolitan"]} +} + + +async def test_valid_state( + hass: HomeAssistant, aioclient_mock: AiohttpClientMocker +) -> None: + """Test for operational london_underground sensor with proper attributes.""" + aioclient_mock.get( + API_URL, + text=load_fixture("line_status.json", "london_underground"), + ) + + assert await async_setup_component(hass, "sensor", VALID_CONFIG) + await hass.async_block_till_done() + + state = hass.states.get("sensor.metropolitan") + assert state + assert state.state == "Good Service" + assert state.attributes == { + "Description": "Nothing to report", + "attribution": "Powered by TfL Open Data", + "friendly_name": "Metropolitan", + "icon": "mdi:subway", + } From 15caf2ac03a126a4f00fe97d44aa3ce997708176 Mon Sep 17 00:00:00 2001 From: Mike <7278201+mike391@users.noreply.github.com> Date: Thu, 21 Sep 2023 04:53:18 -0400 Subject: [PATCH 629/640] Add support for Levoit Vital200s purifier (#100613) --- homeassistant/components/vesync/const.py | 7 ++++++- homeassistant/components/vesync/fan.py | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vesync/const.py b/homeassistant/components/vesync/const.py index b20a04b8a1c9e8..f87f1cf3a8a540 100644 --- a/homeassistant/components/vesync/const.py +++ b/homeassistant/components/vesync/const.py @@ -35,5 +35,10 @@ "Core600S": "Core600S", "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-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", } diff --git a/homeassistant/components/vesync/fan.py b/homeassistant/components/vesync/fan.py index e5347b204e672d..87934ced81fe02 100644 --- a/homeassistant/components/vesync/fan.py +++ b/homeassistant/components/vesync/fan.py @@ -27,10 +27,12 @@ "Core300S": "fan", "Core400S": "fan", "Core600S": "fan", + "Vital200S": "fan", } FAN_MODE_AUTO = "auto" FAN_MODE_SLEEP = "sleep" +FAN_MODE_PET = "pet" PRESET_MODES = { "LV-PUR131S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], @@ -38,6 +40,7 @@ "Core300S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core400S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], "Core600S": [FAN_MODE_AUTO, FAN_MODE_SLEEP], + "Vital200S": [FAN_MODE_AUTO, FAN_MODE_SLEEP, FAN_MODE_PET], } SPEED_RANGE = { # off is not included "LV-PUR131S": (1, 3), @@ -45,6 +48,7 @@ "Core300S": (1, 3), "Core400S": (1, 4), "Core600S": (1, 4), + "Vital200S": (1, 4), } From e4742c04f201c8520acfc3888f570e9ae171a314 Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Thu, 21 Sep 2023 10:57:23 +0200 Subject: [PATCH 630/640] Fix missspelled package names (#100670) --- .../components/aquostv/manifest.json | 2 +- .../components/asterisk_mbox/manifest.json | 2 +- .../components/duotecno/manifest.json | 2 +- .../components/emulated_hue/manifest.json | 2 +- .../components/esphome/manifest.json | 2 +- homeassistant/components/foobot/manifest.json | 2 +- .../gardena_bluetooth/manifest.json | 2 +- .../components/greeneye_monitor/manifest.json | 2 +- homeassistant/components/http/manifest.json | 2 +- .../components/pushover/manifest.json | 2 +- .../components/tplink_omada/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 28 +++++++++---------- requirements_test.txt | 2 +- requirements_test_all.txt | 24 ++++++++-------- 15 files changed, 39 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/aquostv/manifest.json b/homeassistant/components/aquostv/manifest.json index 011b8e67a19c57..1bac2bdfb5ff8a 100644 --- a/homeassistant/components/aquostv/manifest.json +++ b/homeassistant/components/aquostv/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/aquostv", "iot_class": "local_polling", "loggers": ["sharp_aquos_rc"], - "requirements": ["sharp-aquos-rc==0.3.2"] + "requirements": ["sharp_aquos_rc==0.3.2"] } diff --git a/homeassistant/components/asterisk_mbox/manifest.json b/homeassistant/components/asterisk_mbox/manifest.json index 840c48aff2aa29..8348e40ba6b9aa 100644 --- a/homeassistant/components/asterisk_mbox/manifest.json +++ b/homeassistant/components/asterisk_mbox/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/asterisk_mbox", "iot_class": "local_push", "loggers": ["asterisk_mbox"], - "requirements": ["asterisk-mbox==0.5.0"] + "requirements": ["asterisk_mbox==0.5.0"] } diff --git a/homeassistant/components/duotecno/manifest.json b/homeassistant/components/duotecno/manifest.json index d26d4fce61e656..be2a74f884f42d 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.8.4"] + "requirements": ["pyDuotecno==2023.8.4"] } diff --git a/homeassistant/components/emulated_hue/manifest.json b/homeassistant/components/emulated_hue/manifest.json index 01dae2dca77275..ff3591e00667d5 100644 --- a/homeassistant/components/emulated_hue/manifest.json +++ b/homeassistant/components/emulated_hue/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/emulated_hue", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/esphome/manifest.json b/homeassistant/components/esphome/manifest.json index e311a0913aeacc..65c5bf44d5b00b 100644 --- a/homeassistant/components/esphome/manifest.json +++ b/homeassistant/components/esphome/manifest.json @@ -15,7 +15,7 @@ "iot_class": "local_push", "loggers": ["aioesphomeapi", "noiseprotocol"], "requirements": [ - "async_interrupt==1.1.1", + "async-interrupt==1.1.1", "aioesphomeapi==16.0.5", "bluetooth-data-tools==1.11.0", "esphome-dashboard-api==1.2.3" diff --git a/homeassistant/components/foobot/manifest.json b/homeassistant/components/foobot/manifest.json index 890cd95784cc5a..a517f1fea6fa03 100644 --- a/homeassistant/components/foobot/manifest.json +++ b/homeassistant/components/foobot/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/foobot", "iot_class": "cloud_polling", "loggers": ["foobot_async"], - "requirements": ["foobot-async==1.0.0"] + "requirements": ["foobot_async==1.0.0"] } diff --git a/homeassistant/components/gardena_bluetooth/manifest.json b/homeassistant/components/gardena_bluetooth/manifest.json index 3e07eb1ad425f6..bcbb25d55a2640 100644 --- a/homeassistant/components/gardena_bluetooth/manifest.json +++ b/homeassistant/components/gardena_bluetooth/manifest.json @@ -13,5 +13,5 @@ "dependencies": ["bluetooth_adapters"], "documentation": "https://www.home-assistant.io/integrations/gardena_bluetooth", "iot_class": "local_polling", - "requirements": ["gardena_bluetooth==1.4.0"] + "requirements": ["gardena-bluetooth==1.4.0"] } diff --git a/homeassistant/components/greeneye_monitor/manifest.json b/homeassistant/components/greeneye_monitor/manifest.json index 33a4947c01d230..fcf4d004d26565 100644 --- a/homeassistant/components/greeneye_monitor/manifest.json +++ b/homeassistant/components/greeneye_monitor/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/greeneye_monitor", "iot_class": "local_push", "loggers": ["greeneye"], - "requirements": ["greeneye-monitor==3.0.3"] + "requirements": ["greeneye_monitor==3.0.3"] } diff --git a/homeassistant/components/http/manifest.json b/homeassistant/components/http/manifest.json index dec1b9485b6921..bce425adbdb6e9 100644 --- a/homeassistant/components/http/manifest.json +++ b/homeassistant/components/http/manifest.json @@ -6,5 +6,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiohttp-cors==0.7.0"] + "requirements": ["aiohttp_cors==0.7.0"] } diff --git a/homeassistant/components/pushover/manifest.json b/homeassistant/components/pushover/manifest.json index 3b538f756e0591..d086321c0885ce 100644 --- a/homeassistant/components/pushover/manifest.json +++ b/homeassistant/components/pushover/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/pushover", "iot_class": "cloud_push", "loggers": ["pushover_complete"], - "requirements": ["pushover-complete==1.1.1"] + "requirements": ["pushover_complete==1.1.1"] } diff --git a/homeassistant/components/tplink_omada/manifest.json b/homeassistant/components/tplink_omada/manifest.json index 9c303b24661adf..3215a9ba77dc57 100644 --- a/homeassistant/components/tplink_omada/manifest.json +++ b/homeassistant/components/tplink_omada/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/tplink_omada", "integration_type": "hub", "iot_class": "local_polling", - "requirements": ["tplink_omada_client==1.3.2"] + "requirements": ["tplink-omada-client==1.3.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b1ad7f7a3c5404..6c65a08a97e97d 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,6 +1,6 @@ aiodiscover==1.5.1 -aiohttp-cors==0.7.0 aiohttp==3.8.5 +aiohttp_cors==0.7.0 astral==2.2 async-timeout==4.0.3 async-upnp-client==0.35.1 diff --git a/requirements_all.txt b/requirements_all.txt index e7cb02e348f915..c059d20cbd5dbd 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -254,7 +254,7 @@ aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -448,7 +448,10 @@ arris-tg2492lg==1.2.1 asmog==0.0.6 # homeassistant.components.asterisk_mbox -asterisk-mbox==0.5.0 +asterisk_mbox==0.5.0 + +# homeassistant.components.esphome +async-interrupt==1.1.1 # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms @@ -458,9 +461,6 @@ asterisk-mbox==0.5.0 # homeassistant.components.yeelight async-upnp-client==0.35.1 -# homeassistant.components.esphome -async_interrupt==1.1.1 - # homeassistant.components.keyboard_remote asyncinotify==4.0.2 @@ -818,7 +818,7 @@ flux-led==1.0.4 fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -840,7 +840,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -922,7 +922,7 @@ gps3==0.33.3 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.greenwave greenwavereality==0.5.1 @@ -1488,7 +1488,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1535,6 +1535,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1662,9 +1665,6 @@ pydrawise==2023.8.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 - # homeassistant.components.ebox pyebox==1.1.4 @@ -2398,7 +2398,7 @@ sfrbox-api==0.0.6 sharkiq==1.0.2 # homeassistant.components.aquostv -sharp-aquos-rc==0.3.2 +sharp_aquos_rc==0.3.2 # homeassistant.components.shodan shodan==1.28.0 @@ -2587,7 +2587,7 @@ total-connect-client==2023.2 tp-connected==0.0.4 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 diff --git a/requirements_test.txt b/requirements_test.txt index 8da4e92c81d1df..2d0c256ac26283 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -29,7 +29,7 @@ pytest-unordered==0.5.2 pytest-picked==0.4.6 pytest-xdist==3.3.1 pytest==7.3.1 -requests_mock==1.11.0 +requests-mock==1.11.0 respx==0.20.2 syrupy==4.5.0 tqdm==4.66.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0f6f5d3880619a..dbd433aa4c00ba 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -232,7 +232,7 @@ aiohomekit==3.0.3 # homeassistant.components.emulated_hue # homeassistant.components.http -aiohttp-cors==0.7.0 +aiohttp_cors==0.7.0 # homeassistant.components.hue aiohue==4.6.2 @@ -404,6 +404,9 @@ aranet4==2.1.3 # homeassistant.components.arcam_fmj arcam-fmj==1.4.0 +# homeassistant.components.esphome +async-interrupt==1.1.1 + # homeassistant.components.dlna_dmr # homeassistant.components.dlna_dms # homeassistant.components.samsungtv @@ -412,9 +415,6 @@ arcam-fmj==1.4.0 # homeassistant.components.yeelight async-upnp-client==0.35.1 -# homeassistant.components.esphome -async_interrupt==1.1.1 - # homeassistant.components.sleepiq asyncsleepiq==1.3.7 @@ -646,7 +646,7 @@ flux-led==1.0.4 fnv-hash-fast==0.4.1 # homeassistant.components.foobot -foobot-async==1.0.0 +foobot_async==1.0.0 # homeassistant.components.forecast_solar forecast-solar==3.0.0 @@ -662,7 +662,7 @@ fritzconnection[qr]==1.13.2 gTTS==2.2.4 # homeassistant.components.gardena_bluetooth -gardena_bluetooth==1.4.0 +gardena-bluetooth==1.4.0 # homeassistant.components.google_assistant_sdk gassist-text==0.0.10 @@ -726,7 +726,7 @@ govee-ble==0.23.0 greeclimate==1.4.1 # homeassistant.components.greeneye_monitor -greeneye-monitor==3.0.3 +greeneye_monitor==3.0.3 # homeassistant.components.pure_energie gridnet==4.2.0 @@ -1130,7 +1130,7 @@ pure-python-adb[async]==0.3.0.dev0 pushbullet.py==0.11.0 # homeassistant.components.pushover -pushover-complete==1.1.1 +pushover_complete==1.1.1 # homeassistant.components.pvoutput pvo==1.0.0 @@ -1165,6 +1165,9 @@ pyCEC==0.5.2 # homeassistant.components.control4 pyControl4==1.1.0 +# homeassistant.components.duotecno +pyDuotecno==2023.8.4 + # homeassistant.components.eight_sleep pyEight==0.3.2 @@ -1241,9 +1244,6 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 -# homeassistant.components.duotecno -pyduotecno==2023.8.4 - # homeassistant.components.econet pyeconet==0.1.20 @@ -1899,7 +1899,7 @@ toonapi==0.2.1 total-connect-client==2023.2 # homeassistant.components.tplink_omada -tplink_omada_client==1.3.2 +tplink-omada-client==1.3.2 # homeassistant.components.transmission transmission-rpc==4.1.5 From 11c4c37cf9f85f8e6c5a6c97eb830b9e90dcf5da Mon Sep 17 00:00:00 2001 From: Fletcher Date: Thu, 21 Sep 2023 17:06:55 +0800 Subject: [PATCH 631/640] Add Slack thread/reply support (#93384) --- CODEOWNERS | 4 +-- homeassistant/components/slack/const.py | 1 + homeassistant/components/slack/manifest.json | 2 +- homeassistant/components/slack/notify.py | 29 ++++++++++++++++++-- tests/components/slack/test_notify.py | 16 +++++++++++ 5 files changed, 46 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index f3ff40246776b6..5bd97369ef517c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1145,8 +1145,8 @@ build.json @home-assistant/supervisor /homeassistant/components/sky_hub/ @rogerselwyn /homeassistant/components/skybell/ @tkdrob /tests/components/skybell/ @tkdrob -/homeassistant/components/slack/ @tkdrob -/tests/components/slack/ @tkdrob +/homeassistant/components/slack/ @tkdrob @fletcherau +/tests/components/slack/ @tkdrob @fletcherau /homeassistant/components/sleepiq/ @mfugate1 @kbickar /tests/components/sleepiq/ @mfugate1 @kbickar /homeassistant/components/slide/ @ualex73 diff --git a/homeassistant/components/slack/const.py b/homeassistant/components/slack/const.py index ec0993e290b183..ccc1fbb664366c 100644 --- a/homeassistant/components/slack/const.py +++ b/homeassistant/components/slack/const.py @@ -10,6 +10,7 @@ ATTR_URL = "url" ATTR_USERNAME = "username" ATTR_USER_ID = "user_id" +ATTR_THREAD_TS = "thread_ts" CONF_DEFAULT_CHANNEL = "default_channel" diff --git a/homeassistant/components/slack/manifest.json b/homeassistant/components/slack/manifest.json index 2bd3476cbbe40f..1b35db6f061dee 100644 --- a/homeassistant/components/slack/manifest.json +++ b/homeassistant/components/slack/manifest.json @@ -1,7 +1,7 @@ { "domain": "slack", "name": "Slack", - "codeowners": ["@tkdrob"], + "codeowners": ["@tkdrob", "@fletcherau"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/slack", "integration_type": "service", diff --git a/homeassistant/components/slack/notify.py b/homeassistant/components/slack/notify.py index 498eddffa3d88b..deba0796750b62 100644 --- a/homeassistant/components/slack/notify.py +++ b/homeassistant/components/slack/notify.py @@ -30,6 +30,7 @@ ATTR_FILE, ATTR_PASSWORD, ATTR_PATH, + ATTR_THREAD_TS, ATTR_URL, ATTR_USERNAME, CONF_DEFAULT_CHANNEL, @@ -50,7 +51,10 @@ ) DATA_FILE_SCHEMA = vol.Schema( - {vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA)} + { + vol.Required(ATTR_FILE): vol.Any(FILE_PATH_SCHEMA, FILE_URL_SCHEMA), + vol.Optional(ATTR_THREAD_TS): cv.string, + } ) DATA_TEXT_ONLY_SCHEMA = vol.Schema( @@ -59,6 +63,7 @@ vol.Optional(ATTR_ICON): cv.string, vol.Optional(ATTR_BLOCKS): list, vol.Optional(ATTR_BLOCKS_TEMPLATE): list, + vol.Optional(ATTR_THREAD_TS): cv.string, } ) @@ -73,7 +78,7 @@ class AuthDictT(TypedDict, total=False): auth: BasicAuth -class FormDataT(TypedDict): +class FormDataT(TypedDict, total=False): """Type for form data, file upload.""" channels: str @@ -81,6 +86,7 @@ class FormDataT(TypedDict): initial_comment: str title: str token: str + thread_ts: str # Optional key class MessageT(TypedDict, total=False): @@ -92,6 +98,7 @@ class MessageT(TypedDict, total=False): icon_url: str # Optional key icon_emoji: str # Optional key blocks: list[Any] # Optional key + thread_ts: str # Optional key async def async_get_service( @@ -142,6 +149,7 @@ async def _async_send_local_file_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, ) -> None: """Upload a local file (with message) to Slack.""" if not self._hass.config.is_allowed_path(path): @@ -158,6 +166,7 @@ async def _async_send_local_file_message( filename=filename, initial_comment=message, title=title or filename, + thread_ts=thread_ts, ) except (SlackApiError, ClientError) as err: _LOGGER.error("Error while uploading file-based message: %r", err) @@ -168,6 +177,7 @@ async def _async_send_remote_file_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, password: str | None = None, @@ -205,6 +215,9 @@ async def _async_send_remote_file_message( "token": self._client.token, } + if thread_ts: + form_data["thread_ts"] = thread_ts + data = FormData(form_data, charset="utf-8") data.add_field("file", resp.content, filename=filename) @@ -218,6 +231,7 @@ async def _async_send_text_only_message( targets: list[str], message: str, title: str | None, + thread_ts: str | None, *, username: str | None = None, icon: str | None = None, @@ -238,6 +252,9 @@ async def _async_send_text_only_message( if blocks: message_dict["blocks"] = blocks + if thread_ts: + message_dict["thread_ts"] = thread_ts + tasks = { target: self._client.chat_postMessage(**message_dict, channel=target) for target in targets @@ -286,6 +303,7 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: title, username=data.get(ATTR_USERNAME, self._config.get(ATTR_USERNAME)), icon=data.get(ATTR_ICON, self._config.get(ATTR_ICON)), + thread_ts=data.get(ATTR_THREAD_TS), blocks=blocks, ) @@ -296,11 +314,16 @@ async def async_send_message(self, message: str, **kwargs: Any) -> None: targets, message, title, + thread_ts=data.get(ATTR_THREAD_TS), username=data[ATTR_FILE].get(ATTR_USERNAME), password=data[ATTR_FILE].get(ATTR_PASSWORD), ) # Message Type 3: A message that uploads a local file return await self._async_send_local_file_message( - data[ATTR_FILE][ATTR_PATH], targets, message, title + data[ATTR_FILE][ATTR_PATH], + targets, + message, + title, + thread_ts=data.get(ATTR_THREAD_TS), ) diff --git a/tests/components/slack/test_notify.py b/tests/components/slack/test_notify.py index 232f78e97e4386..6c90ad8cd392b2 100644 --- a/tests/components/slack/test_notify.py +++ b/tests/components/slack/test_notify.py @@ -6,6 +6,7 @@ from homeassistant.components import notify from homeassistant.components.slack import DOMAIN from homeassistant.components.slack.notify import ( + ATTR_THREAD_TS, CONF_DEFAULT_CHANNEL, SlackNotificationService, ) @@ -93,3 +94,18 @@ async def test_message_icon_url_overrides_default() -> None: mock_fn.assert_called_once() _, kwargs = mock_fn.call_args assert kwargs["icon_url"] == expected_icon + + +async def test_message_as_reply() -> None: + """Tests that a message pointer will be passed to Slack if specified.""" + mock_client = Mock() + mock_client.chat_postMessage = AsyncMock() + service = SlackNotificationService(None, mock_client, CONF_DATA) + + expected_ts = "1624146685.064129" + await service.async_send_message("test", data={ATTR_THREAD_TS: expected_ts}) + + mock_fn = mock_client.chat_postMessage + mock_fn.assert_called_once() + _, kwargs = mock_fn.call_args + assert kwargs["thread_ts"] == expected_ts From aed3ba3acd89099c28e00478c57fa80657da8920 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Thu, 21 Sep 2023 13:33:26 +0200 Subject: [PATCH 632/640] Avoid redundant calls to `async_ha_write_state` in MQTT (binary) sensor (#100438) * Only call `async_ha_write_state` on changes. * Make helper class * Use UndefinedType * Remove del * Integrate monitor into MqttEntity * Track extra state attributes and availability * Add `__slots__` * Add monitor to MqttAttributes and MqttAvailability * Write out loop * Add test * Make common test and parameterize * Add test for last_reset attribute * MqttMonitorEntity base class * Rename attr and update docstr `track` method. * correction doct * Implement as a decorator * Move tracking functions into decorator * Rename decorator * Follow up comment --- .../components/mqtt/binary_sensor.py | 5 +-- homeassistant/components/mqtt/mixins.py | 45 ++++++++++++++++--- homeassistant/components/mqtt/sensor.py | 4 +- tests/components/mqtt/test_binary_sensor.py | 36 +++++++++++++++ tests/components/mqtt/test_common.py | 25 +++++++++++ tests/components/mqtt/test_sensor.py | 43 ++++++++++++++++++ 6 files changed, 147 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index a1341350a7a4e7..505305cad3e39c 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -43,9 +43,9 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import MqttValueTemplate, ReceiveMessage -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -191,6 +191,7 @@ def off_delay_listener(now: datetime) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_is_on"}) def state_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT state message.""" # auto-expire enabled? @@ -257,8 +258,6 @@ def state_message_received(msg: ReceiveMessage) -> None: self.hass, off_delay, off_delay_listener ) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._sub_state = subscription.async_prepare_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index 795eb30e8e2715..a01691f0601488 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -4,7 +4,7 @@ from abc import ABC, abstractmethod import asyncio from collections.abc import Callable, Coroutine -from functools import partial +from functools import partial, wraps import logging from typing import TYPE_CHECKING, Any, Protocol, cast, final @@ -101,6 +101,7 @@ set_discovery_hash, ) from .models import ( + MessageCallbackType, MqttValueTemplate, PublishPayloadType, ReceiveMessage, @@ -346,6 +347,41 @@ def init_entity_id_from_config( ) +def write_state_on_attr_change( + entity: Entity, attributes: set[str] +) -> Callable[[MessageCallbackType], MessageCallbackType]: + """Wrap an MQTT message callback to track state attribute changes.""" + + def _attrs_have_changed(tracked_attrs: dict[str, Any]) -> bool: + """Return True if attributes on entity changed or if update is forced.""" + if not (write_state := (getattr(entity, "_attr_force_update", False))): + for attribute, last_value in tracked_attrs.items(): + if getattr(entity, attribute, UNDEFINED) != last_value: + write_state = True + break + + return write_state + + def _decorator(msg_callback: MessageCallbackType) -> MessageCallbackType: + @wraps(msg_callback) + def wrapper(msg: ReceiveMessage) -> None: + """Track attributes for write state requests.""" + tracked_attrs: dict[str, Any] = { + attribute: getattr(entity, attribute, UNDEFINED) + for attribute in attributes + } + msg_callback(msg) + if not _attrs_have_changed(tracked_attrs): + return + + mqtt_data = get_mqtt_data(entity.hass) + mqtt_data.state_write_requests.write_state_request(entity) + + return wrapper + + return _decorator + + class MqttAttributes(Entity): """Mixin used for platforms that support JSON attributes.""" @@ -379,6 +415,7 @@ def _attributes_prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"_attr_extra_state_attributes"}) def attributes_message_received(msg: ReceiveMessage) -> None: try: payload = attr_tpl(msg.payload) @@ -391,9 +428,6 @@ def attributes_message_received(msg: ReceiveMessage) -> None: and k not in self._attributes_extra_blocked } self._attr_extra_state_attributes = filtered_dict - get_mqtt_data(self.hass).state_write_requests.write_state_request( - self - ) else: _LOGGER.warning("JSON result was not a dictionary") except ValueError: @@ -488,6 +522,7 @@ def _availability_prepare_subscribe_topics(self) -> None: @callback @log_messages(self.hass, self.entity_id) + @write_state_on_attr_change(self, {"available"}) def availability_message_received(msg: ReceiveMessage) -> None: """Handle a new received MQTT availability message.""" topic = msg.topic @@ -500,8 +535,6 @@ def availability_message_received(msg: ReceiveMessage) -> None: self._available[topic] = False self._available_latest = False - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) - self._available = { topic: (self._available[topic] if topic in self._available else False) for topic in self._avail_topics diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 70c8d505b4f8f2..278e70a9737995 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -45,6 +45,7 @@ MqttAvailability, MqttEntity, async_setup_entry_helper, + write_state_on_attr_change, ) from .models import ( MqttValueTemplate, @@ -52,7 +53,6 @@ ReceiveMessage, ReceivePayloadType, ) -from .util import get_mqtt_data _LOGGER = logging.getLogger(__name__) @@ -287,13 +287,13 @@ def _update_last_reset(msg: ReceiveMessage) -> None: ) @callback + @write_state_on_attr_change(self, {"_attr_native_value", "_attr_last_reset"}) @log_messages(self.hass, self.entity_id) def message_received(msg: ReceiveMessage) -> None: """Handle new MQTT messages.""" _update_state(msg) if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) - get_mqtt_data(self.hass).state_write_requests.write_state_request(self) topics["state_topic"] = { "topic": self._config[CONF_STATE_TOPIC], diff --git a/tests/components/mqtt/test_binary_sensor.py b/tests/components/mqtt/test_binary_sensor.py index 91a4833b1fcfd5..ea9c8072290a23 100644 --- a/tests/components/mqtt/test_binary_sensor.py +++ b/tests/components/mqtt/test_binary_sensor.py @@ -47,6 +47,7 @@ help_test_reloadable, help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1248,3 +1249,38 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + binary_sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", "ON", "OFF"), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """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) diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 9aa88c2d7ba072..64bece5369e5d2 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -1925,3 +1925,28 @@ async def help_test_discovery_setup( await hass.async_block_till_done() state = hass.states.get(f"{domain}.{name}") assert state and state.state is not None + + +async def help_test_skipped_async_ha_write_state( + hass: HomeAssistant, topic: str, payload1: str, payload2: str +) -> None: + """Test entity.async_ha_write_state is only called on changes.""" + with patch( + "homeassistant.components.mqtt.mixins.MqttEntity.async_write_ha_state" + ) as mock_async_ha_write_state: + assert len(mock_async_ha_write_state.mock_calls) == 0 + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload1) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 1 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 + + async_fire_mqtt_message(hass, topic, payload2) + await hass.async_block_till_done() + assert len(mock_async_ha_write_state.mock_calls) == 2 diff --git a/tests/components/mqtt/test_sensor.py b/tests/components/mqtt/test_sensor.py index d9c92b315b3d62..bc75492a03e27c 100644 --- a/tests/components/mqtt/test_sensor.py +++ b/tests/components/mqtt/test_sensor.py @@ -60,6 +60,7 @@ help_test_setting_attribute_via_mqtt_json_message, help_test_setting_attribute_with_template, help_test_setting_blocked_attribute_via_mqtt_json_message, + help_test_skipped_async_ha_write_state, help_test_unique_id, help_test_unload_config_entry_with_platform, help_test_update_with_json_attrs_bad_json, @@ -1437,3 +1438,45 @@ async def test_entity_name( await help_test_entity_name( hass, mqtt_mock_entry, domain, config, expected_friendly_name, device_class ) + + +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + sensor.DOMAIN, + DEFAULT_CONFIG, + ( + { + "availability_topic": "availability-topic", + "json_attributes_topic": "json-attributes-topic", + "value_template": "{{ value_json.state }}", + "last_reset_value_template": "{{ value_json.last_reset }}", + }, + ), + ) + ], +) +@pytest.mark.parametrize( + ("topic", "payload1", "payload2"), + [ + ("test-topic", '{"state":"val1"}', '{"state":"val2"}'), + ( + "test-topic", + '{"last_reset":"2023-09-15 15:11:03"}', + '{"last_reset":"2023-09-16 15:11:02"}', + ), + ("availability-topic", "online", "offline"), + ("json-attributes-topic", '{"attr1": "val1"}', '{"attr1": "val2"}'), + ], +) +async def test_skipped_async_ha_write_state( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + topic: str, + payload1: str, + payload2: str, +) -> None: + """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) From df73850f5679e260da29028a74676395ec5a9bfb Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Sep 2023 15:02:47 +0200 Subject: [PATCH 633/640] Move definition of attributes excluded from history to entity classes (#100430) * Move definition of attributes excluded from history to entity classes * Revert change which should be in a follow-up PR * Fix sun unrecorded attributes * Fix input_select unrecorded attributes --- .../components/automation/__init__.py | 7 ---- homeassistant/components/calendar/__init__.py | 2 ++ homeassistant/components/calendar/recorder.py | 10 ------ homeassistant/components/camera/__init__.py | 4 +++ homeassistant/components/camera/recorder.py | 10 ------ homeassistant/components/climate/__init__.py | 14 ++++++++ homeassistant/components/climate/recorder.py | 32 ------------------- homeassistant/components/event/__init__.py | 2 ++ homeassistant/components/event/recorder.py | 12 ------- homeassistant/components/fan/__init__.py | 2 ++ homeassistant/components/fan/recorder.py | 12 ------- homeassistant/components/group/__init__.py | 7 ++-- .../components/group/media_player.py | 2 ++ homeassistant/components/group/recorder.py | 16 ---------- .../components/humidifier/__init__.py | 4 +++ .../components/humidifier/recorder.py | 16 ---------- homeassistant/components/image/__init__.py | 4 +++ homeassistant/components/image/recorder.py | 10 ------ .../components/input_boolean/__init__.py | 9 ++---- .../components/input_boolean/recorder.py | 11 ------- .../components/input_button/__init__.py | 9 ++---- .../components/input_button/recorder.py | 11 ------- .../components/input_datetime/__init__.py | 9 ++---- .../components/input_datetime/recorder.py | 13 -------- .../components/input_number/__init__.py | 11 +++---- .../components/input_number/recorder.py | 19 ----------- .../components/input_select/__init__.py | 12 +++---- .../components/input_select/recorder.py | 11 ------- .../components/input_text/__init__.py | 11 +++---- .../components/input_text/recorder.py | 19 ----------- homeassistant/components/light/__init__.py | 11 +++++++ homeassistant/components/light/recorder.py | 26 --------------- .../components/media_player/__init__.py | 12 +++++++ .../components/media_player/recorder.py | 26 --------------- homeassistant/components/number/__init__.py | 4 +++ homeassistant/components/number/recorder.py | 17 ---------- homeassistant/components/person/__init__.py | 8 ++--- homeassistant/components/person/recorder.py | 12 ------- homeassistant/components/schedule/__init__.py | 11 +++---- homeassistant/components/schedule/recorder.py | 16 ---------- homeassistant/components/script/__init__.py | 11 +++---- homeassistant/components/script/recorder.py | 12 ------- homeassistant/components/select/__init__.py | 2 ++ homeassistant/components/select/recorder.py | 12 ------- homeassistant/components/sensor/__init__.py | 2 ++ homeassistant/components/sensor/recorder.py | 16 ++-------- homeassistant/components/siren/__init__.py | 2 ++ homeassistant/components/siren/recorder.py | 12 ------- homeassistant/components/sun/__init__.py | 26 +++++++++++---- homeassistant/components/sun/recorder.py | 32 ------------------- homeassistant/components/text/__init__.py | 4 +++ homeassistant/components/text/recorder.py | 12 ------- .../trafikverket_camera/__init__.py | 12 ------- .../components/trafikverket_camera/camera.py | 2 ++ .../trafikverket_camera/recorder.py | 13 -------- .../components/unifiprotect/entity.py | 2 ++ .../components/unifiprotect/recorder.py | 12 ------- homeassistant/components/update/__init__.py | 6 +++- homeassistant/components/update/recorder.py | 13 -------- homeassistant/components/vacuum/__init__.py | 2 ++ homeassistant/components/vacuum/recorder.py | 12 ------- .../components/water_heater/__init__.py | 4 +++ .../components/water_heater/recorder.py | 12 ------- homeassistant/components/weather/__init__.py | 2 ++ homeassistant/components/weather/recorder.py | 12 ------- 65 files changed, 143 insertions(+), 558 deletions(-) delete mode 100644 homeassistant/components/calendar/recorder.py delete mode 100644 homeassistant/components/camera/recorder.py delete mode 100644 homeassistant/components/climate/recorder.py delete mode 100644 homeassistant/components/event/recorder.py delete mode 100644 homeassistant/components/fan/recorder.py delete mode 100644 homeassistant/components/group/recorder.py delete mode 100644 homeassistant/components/humidifier/recorder.py delete mode 100644 homeassistant/components/image/recorder.py delete mode 100644 homeassistant/components/input_boolean/recorder.py delete mode 100644 homeassistant/components/input_button/recorder.py delete mode 100644 homeassistant/components/input_datetime/recorder.py delete mode 100644 homeassistant/components/input_number/recorder.py delete mode 100644 homeassistant/components/input_select/recorder.py delete mode 100644 homeassistant/components/input_text/recorder.py delete mode 100644 homeassistant/components/light/recorder.py delete mode 100644 homeassistant/components/media_player/recorder.py delete mode 100644 homeassistant/components/number/recorder.py delete mode 100644 homeassistant/components/person/recorder.py delete mode 100644 homeassistant/components/schedule/recorder.py delete mode 100644 homeassistant/components/script/recorder.py delete mode 100644 homeassistant/components/select/recorder.py delete mode 100644 homeassistant/components/siren/recorder.py delete mode 100644 homeassistant/components/sun/recorder.py delete mode 100644 homeassistant/components/text/recorder.py delete mode 100644 homeassistant/components/trafikverket_camera/recorder.py delete mode 100644 homeassistant/components/unifiprotect/recorder.py delete mode 100644 homeassistant/components/update/recorder.py delete mode 100644 homeassistant/components/vacuum/recorder.py delete mode 100644 homeassistant/components/water_heater/recorder.py delete mode 100644 homeassistant/components/weather/recorder.py diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index fd6a70cce46e58..df388e52a7f63f 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -57,9 +57,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( @@ -249,10 +246,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register automation as valid domain for Blueprint async_get_blueprints(hass) diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index e487569453fa75..96872e039e1d59 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -481,6 +481,8 @@ def is_offset_reached( class CalendarEntity(Entity): """Base class for calendar event entities.""" + _entity_component_unrecorded_attributes = frozenset({"description"}) + _alarm_unsubs: list[CALLBACK_TYPE] = [] @property diff --git a/homeassistant/components/calendar/recorder.py b/homeassistant/components/calendar/recorder.py deleted file mode 100644 index 4aba7b409cc5a1..00000000000000 --- a/homeassistant/components/calendar/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude potentially large attributes from being recorded in the database.""" - return {"description"} diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 07394ca75b2921..bb5a44a530c6f0 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -449,6 +449,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Camera(Entity): """The base class for camera entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_brand: str | None = None _attr_frame_interval: float = MIN_STREAM_INTERVAL diff --git a/homeassistant/components/camera/recorder.py b/homeassistant/components/camera/recorder.py deleted file mode 100644 index 5c14122088100c..00000000000000 --- a/homeassistant/components/camera/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index dfc428a9bd06e0..a075467a313fc9 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -209,6 +209,20 @@ class ClimateEntityDescription(EntityDescription): class ClimateEntity(Entity): """Base class for climate entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_HVAC_MODES, + ATTR_FAN_MODES, + ATTR_SWING_MODES, + ATTR_MIN_TEMP, + ATTR_MAX_TEMP, + ATTR_MIN_HUMIDITY, + ATTR_MAX_HUMIDITY, + ATTR_TARGET_TEMP_STEP, + ATTR_PRESET_MODES, + } + ) + entity_description: ClimateEntityDescription _attr_current_humidity: int | None = None _attr_current_temperature: float | None = None diff --git a/homeassistant/components/climate/recorder.py b/homeassistant/components/climate/recorder.py deleted file mode 100644 index 879e6bfbbac709..00000000000000 --- a/homeassistant/components/climate/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ( - ATTR_FAN_MODES, - ATTR_HVAC_MODES, - ATTR_MAX_HUMIDITY, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MIN_TEMP, - ATTR_PRESET_MODES, - ATTR_SWING_MODES, - ATTR_TARGET_TEMP_STEP, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_HVAC_MODES, - ATTR_FAN_MODES, - ATTR_SWING_MODES, - ATTR_MIN_TEMP, - ATTR_MAX_TEMP, - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_TARGET_TEMP_STEP, - ATTR_PRESET_MODES, - } diff --git a/homeassistant/components/event/__init__.py b/homeassistant/components/event/__init__.py index f6ba2d79bfecba..d960867097276f 100644 --- a/homeassistant/components/event/__init__.py +++ b/homeassistant/components/event/__init__.py @@ -105,6 +105,8 @@ def from_dict(cls, restored: dict[str, Any]) -> Self | None: class EventEntity(RestoreEntity): """Representation of an Event entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_EVENT_TYPES}) + entity_description: EventEntityDescription _attr_device_class: EventDeviceClass | None _attr_event_types: list[str] diff --git a/homeassistant/components/event/recorder.py b/homeassistant/components/event/recorder.py deleted file mode 100644 index 759fd80bcf0e0d..00000000000000 --- a/homeassistant/components/event/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_EVENT_TYPES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_EVENT_TYPES} diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 6aa29d8b8042f0..a149909e029f2d 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -183,6 +183,8 @@ class FanEntityDescription(ToggleEntityDescription): class FanEntity(ToggleEntity): """Base class for fan entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_PRESET_MODES}) + entity_description: FanEntityDescription _attr_current_direction: str | None = None _attr_oscillating: bool | None = None diff --git a/homeassistant/components/fan/recorder.py b/homeassistant/components/fan/recorder.py deleted file mode 100644 index e7305b64f16a23..00000000000000 --- a/homeassistant/components/fan/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_PRESET_MODES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_PRESET_MODES} diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index ef011c4308af69..364ef15fa5e36a 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -42,7 +42,6 @@ async_track_state_change_event, ) from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.helpers.reload import async_reload_integration_platforms @@ -285,8 +284,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: if DOMAIN not in hass.data: hass.data[DOMAIN] = EntityComponent[Group](_LOGGER, DOMAIN, hass) - await async_process_integration_platform_for_component(hass, DOMAIN) - component: EntityComponent[Group] = hass.data[DOMAIN] hass.data[REG_KEY] = GroupIntegrationRegistry() @@ -472,6 +469,8 @@ async def _async_process_config(hass: HomeAssistant, config: ConfigType) -> None class GroupEntity(Entity): """Representation of a Group of entities.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_should_poll = False _entity_ids: list[str] @@ -560,6 +559,8 @@ def async_update_supported_features( class Group(Entity): """Track a group of entity ids.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID, ATTR_ORDER, ATTR_AUTO}) + _attr_should_poll = False tracking: tuple[str, ...] trackable: tuple[str, ...] diff --git a/homeassistant/components/group/media_player.py b/homeassistant/components/group/media_player.py index 3960f400614f4e..bc238519cfa90b 100644 --- a/homeassistant/components/group/media_player.py +++ b/homeassistant/components/group/media_player.py @@ -122,6 +122,8 @@ def async_create_preview_media_player( class MediaPlayerGroup(MediaPlayerEntity): """Representation of a Media Group.""" + _unrecorded_attributes = frozenset({ATTR_ENTITY_ID}) + _attr_available: bool = False _attr_should_poll = False diff --git a/homeassistant/components/group/recorder.py b/homeassistant/components/group/recorder.py deleted file mode 100644 index 9138b4ef348ef9..00000000000000 --- a/homeassistant/components/group/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AUTO, ATTR_ENTITY_ID, ATTR_ORDER - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_ENTITY_ID, - ATTR_ORDER, - ATTR_AUTO, - } diff --git a/homeassistant/components/humidifier/__init__.py b/homeassistant/components/humidifier/__init__.py index a525c626f143a5..47745c53394dc4 100644 --- a/homeassistant/components/humidifier/__init__.py +++ b/homeassistant/components/humidifier/__init__.py @@ -134,6 +134,10 @@ class HumidifierEntityDescription(ToggleEntityDescription): class HumidifierEntity(ToggleEntity): """Base class for humidifier entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN_HUMIDITY, ATTR_MAX_HUMIDITY, ATTR_AVAILABLE_MODES} + ) + entity_description: HumidifierEntityDescription _attr_action: HumidifierAction | None = None _attr_available_modes: list[str] | None diff --git a/homeassistant/components/humidifier/recorder.py b/homeassistant/components/humidifier/recorder.py deleted file mode 100644 index 53df96605d6931..00000000000000 --- a/homeassistant/components/humidifier/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_MODES, ATTR_MAX_HUMIDITY, ATTR_MIN_HUMIDITY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN_HUMIDITY, - ATTR_MAX_HUMIDITY, - ATTR_AVAILABLE_MODES, - } diff --git a/homeassistant/components/image/__init__.py b/homeassistant/components/image/__init__.py index d1895053f02351..e5c40affe0fd22 100644 --- a/homeassistant/components/image/__init__.py +++ b/homeassistant/components/image/__init__.py @@ -126,6 +126,10 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class ImageEntity(Entity): """The base class for image entities.""" + _entity_component_unrecorded_attributes = frozenset( + {"access_token", "entity_picture"} + ) + # Entity Properties _attr_content_type: str = DEFAULT_CONTENT_TYPE _attr_image_last_updated: datetime | None = None diff --git a/homeassistant/components/image/recorder.py b/homeassistant/components/image/recorder.py deleted file mode 100644 index 5c14122088100c..00000000000000 --- a/homeassistant/components/image/recorder.py +++ /dev/null @@ -1,10 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude access_token and entity_picture from being recorded in the database.""" - return {"access_token", "entity_picture"} diff --git a/homeassistant/components/input_boolean/__init__.py b/homeassistant/components/input_boolean/__init__.py index a074b3b9b650a3..613e8829aa1eda 100644 --- a/homeassistant/components/input_boolean/__init__.py +++ b/homeassistant/components/input_boolean/__init__.py @@ -22,9 +22,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -94,10 +91,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input boolean.""" component = EntityComponent[InputBoolean](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -156,6 +149,8 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class InputBoolean(collection.CollectionEntity, ToggleEntity, RestoreEntity): """Representation of a boolean input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_boolean/recorder.py b/homeassistant/components/input_boolean/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_boolean/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_button/__init__.py b/homeassistant/components/input_button/__init__.py index c04b18b0c25a3c..3318354392c089 100644 --- a/homeassistant/components/input_button/__init__.py +++ b/homeassistant/components/input_button/__init__.py @@ -18,9 +18,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -79,10 +76,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input button.""" component = EntityComponent[InputButton](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -137,6 +130,8 @@ async def reload_service_handler(service_call: ServiceCall) -> None: class InputButton(collection.CollectionEntity, ButtonEntity, RestoreEntity): """Representation of a button.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_button/recorder.py b/homeassistant/components/input_button/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_button/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_datetime/__init__.py b/homeassistant/components/input_datetime/__init__.py index 81882137fad371..73a4df12d03f84 100644 --- a/homeassistant/components/input_datetime/__init__.py +++ b/homeassistant/components/input_datetime/__init__.py @@ -20,9 +20,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -132,10 +129,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input datetime.""" component = EntityComponent[InputDatetime](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -225,6 +218,8 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputDatetime(collection.CollectionEntity, RestoreEntity): """Representation of a datetime input.""" + _unrecorded_attributes = frozenset({ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_datetime/recorder.py b/homeassistant/components/input_datetime/recorder.py deleted file mode 100644 index 91c33ee0811a86..00000000000000 --- a/homeassistant/components/input_datetime/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import CONF_HAS_DATE, CONF_HAS_TIME - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude some attributes from being recorded in the database.""" - return {ATTR_EDITABLE, CONF_HAS_DATE, CONF_HAS_TIME} diff --git a/homeassistant/components/input_number/__init__.py b/homeassistant/components/input_number/__init__.py index 197a35246d214b..4a74201be15faf 100644 --- a/homeassistant/components/input_number/__init__.py +++ b/homeassistant/components/input_number/__init__.py @@ -21,9 +21,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input slider.""" component = EntityComponent[InputNumber](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -209,6 +202,10 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputNumber(collection.CollectionEntity, RestoreEntity): """Representation of a slider.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_number/recorder.py b/homeassistant/components/input_number/recorder.py deleted file mode 100644 index 05a5023be0b193..00000000000000 --- a/homeassistant/components/input_number/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_STEP, - } diff --git a/homeassistant/components/input_select/__init__.py b/homeassistant/components/input_select/__init__.py index e1354cb26a505e..4a384e0c17a40e 100644 --- a/homeassistant/components/input_select/__init__.py +++ b/homeassistant/components/input_select/__init__.py @@ -29,9 +29,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -138,10 +135,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[InputSelect](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -255,6 +248,11 @@ async def _update_data( class InputSelect(collection.CollectionEntity, SelectEntity, RestoreEntity): """Representation of a select input.""" + _entity_component_unrecorded_attributes = ( + SelectEntity._entity_component_unrecorded_attributes - {ATTR_OPTIONS} + ) + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_select/recorder.py b/homeassistant/components/input_select/recorder.py deleted file mode 100644 index 8e94dc93f3b6b7..00000000000000 --- a/homeassistant/components/input_select/recorder.py +++ /dev/null @@ -1,11 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return {ATTR_EDITABLE} diff --git a/homeassistant/components/input_text/__init__.py b/homeassistant/components/input_text/__init__.py index 096e7cbb10564d..81b75458dc1dcd 100644 --- a/homeassistant/components/input_text/__init__.py +++ b/homeassistant/components/input_text/__init__.py @@ -20,9 +20,6 @@ from homeassistant.helpers import collection import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity import homeassistant.helpers.service from homeassistant.helpers.storage import Store @@ -110,10 +107,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input text.""" component = EntityComponent[InputText](_LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -187,6 +180,10 @@ async def _update_data(self, item: dict, update_data: dict) -> dict: class InputText(collection.CollectionEntity, RestoreEntity): """Represent a text box.""" + _unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/input_text/recorder.py b/homeassistant/components/input_text/recorder.py deleted file mode 100644 index 0f4969270d00f6..00000000000000 --- a/homeassistant/components/input_text/recorder.py +++ /dev/null @@ -1,19 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude editable hint from being recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_MAX, - ATTR_MIN, - ATTR_MODE, - ATTR_PATTERN, - } diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index f7f0150bdd202e..cfcb1e13a07ea6 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -785,6 +785,17 @@ class LightEntityDescription(ToggleEntityDescription): class LightEntity(ToggleEntity): """Base class for light entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_SUPPORTED_COLOR_MODES, + ATTR_EFFECT_LIST, + ATTR_MIN_MIREDS, + ATTR_MAX_MIREDS, + ATTR_MIN_COLOR_TEMP_KELVIN, + ATTR_MAX_COLOR_TEMP_KELVIN, + } + ) + entity_description: LightEntityDescription _attr_brightness: int | None = None _attr_color_mode: ColorMode | str | None = None diff --git a/homeassistant/components/light/recorder.py b/homeassistant/components/light/recorder.py deleted file mode 100644 index e38ba888e7199c..00000000000000 --- a/homeassistant/components/light/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_EFFECT_LIST, - ATTR_MAX_COLOR_TEMP_KELVIN, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MIN_MIREDS, - ATTR_SUPPORTED_COLOR_MODES, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_SUPPORTED_COLOR_MODES, - ATTR_EFFECT_LIST, - ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, - ATTR_MIN_COLOR_TEMP_KELVIN, - ATTR_MAX_COLOR_TEMP_KELVIN, - } diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 2acb516fa95b97..f3ff925a1a4699 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -27,6 +27,7 @@ from homeassistant.components.websocket_api import ERR_NOT_SUPPORTED, ERR_UNKNOWN_ERROR from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( # noqa: F401 + ATTR_ENTITY_PICTURE, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, @@ -458,6 +459,17 @@ class MediaPlayerEntityDescription(EntityDescription): class MediaPlayerEntity(Entity): """ABC for media player entities.""" + _entity_component_unrecorded_attributes = frozenset( + { + ATTR_ENTITY_PICTURE_LOCAL, + ATTR_ENTITY_PICTURE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_POSITION_UPDATED_AT, + ATTR_MEDIA_POSITION, + ATTR_SOUND_MODE_LIST, + } + ) + entity_description: MediaPlayerEntityDescription _access_token: str | None = None diff --git a/homeassistant/components/media_player/recorder.py b/homeassistant/components/media_player/recorder.py deleted file mode 100644 index 8ced833ebecda9..00000000000000 --- a/homeassistant/components/media_player/recorder.py +++ /dev/null @@ -1,26 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from . import ( - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_SOUND_MODE_LIST, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static and token attributes from being recorded in the database.""" - return { - ATTR_ENTITY_PICTURE_LOCAL, - ATTR_ENTITY_PICTURE, - ATTR_INPUT_SOURCE_LIST, - ATTR_MEDIA_POSITION_UPDATED_AT, - ATTR_MEDIA_POSITION, - ATTR_SOUND_MODE_LIST, - } diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index aa3566c5a95d89..4e0f5059c90da1 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -156,6 +156,10 @@ def floor_decimal(value: float, precision: float = 0) -> float: class NumberEntity(Entity): """Representation of a Number entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MIN, ATTR_MAX, ATTR_STEP, ATTR_MODE} + ) + entity_description: NumberEntityDescription _attr_device_class: NumberDeviceClass | None _attr_max_value: None diff --git a/homeassistant/components/number/recorder.py b/homeassistant/components/number/recorder.py deleted file mode 100644 index 39418a48878b3b..00000000000000 --- a/homeassistant/components/number/recorder.py +++ /dev/null @@ -1,17 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_STEP - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return { - ATTR_MIN, - ATTR_MAX, - ATTR_STEP, - ATTR_MODE, - } diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index ea325380e111b0..49b719a549032a 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -47,9 +47,6 @@ ) from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_state_change_event -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -333,9 +330,6 @@ async def filter_yaml_data(hass: HomeAssistant, persons: list[dict]) -> list[dic async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up the person component.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) entity_component = EntityComponent[Person](_LOGGER, DOMAIN, hass) id_manager = collection.IDManager() yaml_collection = collection.YamlCollection( @@ -397,6 +391,8 @@ async def async_reload_yaml(call: ServiceCall) -> None: class Person(collection.CollectionEntity, RestoreEntity): """Represent a tracked person.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_DEVICE_TRACKERS}) + _attr_should_poll = False editable: bool diff --git a/homeassistant/components/person/recorder.py b/homeassistant/components/person/recorder.py deleted file mode 100644 index 7c0fdf5225800c..00000000000000 --- a/homeassistant/components/person/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_DEVICE_TRACKERS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_DEVICE_TRACKERS} diff --git a/homeassistant/components/schedule/__init__.py b/homeassistant/components/schedule/__init__.py index 2e5fcc27715b6c..2f7831fedd463e 100644 --- a/homeassistant/components/schedule/__init__.py +++ b/homeassistant/components/schedule/__init__.py @@ -30,9 +30,6 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.service import async_register_admin_service from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import ConfigType @@ -157,10 +154,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Set up an input select.""" component = EntityComponent[Schedule](LOGGER, DOMAIN, hass) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - id_manager = IDManager() yaml_collection = YamlCollection(LOGGER, id_manager) @@ -240,6 +233,10 @@ async def _async_load_data(self) -> SerializedStorageCollection | None: class Schedule(CollectionEntity): """Schedule entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_EDITABLE, ATTR_NEXT_EVENT} + ) + _attr_has_entity_name = True _attr_should_poll = False _attr_state: Literal["on", "off"] diff --git a/homeassistant/components/schedule/recorder.py b/homeassistant/components/schedule/recorder.py deleted file mode 100644 index b9911e0544bb22..00000000000000 --- a/homeassistant/components/schedule/recorder.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_EDITABLE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_NEXT_EVENT - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude configuration to be recorded in the database.""" - return { - ATTR_EDITABLE, - ATTR_NEXT_EVENT, - } diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index 13b25a00053171..716f0197c8b485 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -42,9 +42,6 @@ from homeassistant.helpers.config_validation import make_entity_service_schema from homeassistant.helpers.entity import ToggleEntity from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.script import ( ATTR_CUR, @@ -188,10 +185,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: LOGGER, DOMAIN, hass ) - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - # Register script as valid domain for Blueprint async_get_blueprints(hass) @@ -382,6 +375,10 @@ def find_matches( class BaseScriptEntity(ToggleEntity, ABC): """Base class for script entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} + ) + raw_config: ConfigType | None @property diff --git a/homeassistant/components/script/recorder.py b/homeassistant/components/script/recorder.py deleted file mode 100644 index b1afc318b51e1a..00000000000000 --- a/homeassistant/components/script/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_CUR, ATTR_LAST_ACTION, ATTR_LAST_TRIGGERED, ATTR_MAX, ATTR_MODE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude extra attributes from being recorded in the database.""" - return {ATTR_LAST_TRIGGERED, ATTR_MODE, ATTR_CUR, ATTR_MAX, ATTR_LAST_ACTION} diff --git a/homeassistant/components/select/__init__.py b/homeassistant/components/select/__init__.py index a8034588ed1f39..4997e088a54b08 100644 --- a/homeassistant/components/select/__init__.py +++ b/homeassistant/components/select/__init__.py @@ -128,6 +128,8 @@ class SelectEntityDescription(EntityDescription): class SelectEntity(Entity): """Representation of a Select entity.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SelectEntityDescription _attr_current_option: str | None _attr_options: list[str] diff --git a/homeassistant/components/select/recorder.py b/homeassistant/components/select/recorder.py deleted file mode 100644 index 6660c8383d042d..00000000000000 --- a/homeassistant/components/select/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_OPTIONS - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index 6b4e4a17fc2261..4faeca33df58c9 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -149,6 +149,8 @@ class SensorEntityDescription(EntityDescription): class SensorEntity(Entity): """Base class for sensor entities.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_OPTIONS}) + entity_description: SensorEntityDescription _attr_device_class: SensorDeviceClass | None _attr_last_reset: datetime | None diff --git a/homeassistant/components/sensor/recorder.py b/homeassistant/components/sensor/recorder.py index 63096b16cd86b4..2ef1b6854fc0a6 100644 --- a/homeassistant/components/sensor/recorder.py +++ b/homeassistant/components/sensor/recorder.py @@ -30,19 +30,13 @@ UnitOfSoundPressure, UnitOfVolume, ) -from homeassistant.core import HomeAssistant, State, callback, split_entity_id +from homeassistant.core import HomeAssistant, State, split_entity_id from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity import entity_sources from homeassistant.util import dt as dt_util from homeassistant.util.enum import try_parse_enum -from .const import ( - ATTR_LAST_RESET, - ATTR_OPTIONS, - ATTR_STATE_CLASS, - DOMAIN, - SensorStateClass, -) +from .const import ATTR_LAST_RESET, ATTR_STATE_CLASS, DOMAIN, SensorStateClass _LOGGER = logging.getLogger(__name__) @@ -790,9 +784,3 @@ def validate_statistics( ) return validation_result - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude attributes from being recorded in the database.""" - return {ATTR_OPTIONS} diff --git a/homeassistant/components/siren/__init__.py b/homeassistant/components/siren/__init__.py index a8907ba3b687a7..ac02201b92862c 100644 --- a/homeassistant/components/siren/__init__.py +++ b/homeassistant/components/siren/__init__.py @@ -159,6 +159,8 @@ class SirenEntityDescription(ToggleEntityDescription): class SirenEntity(ToggleEntity): """Representation of a siren device.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_AVAILABLE_TONES}) + entity_description: SirenEntityDescription _attr_available_tones: list[int | str] | dict[int, str] | None _attr_supported_features: SirenEntityFeature = SirenEntityFeature(0) diff --git a/homeassistant/components/siren/recorder.py b/homeassistant/components/siren/recorder.py deleted file mode 100644 index 3daf4fc52b21fc..00000000000000 --- a/homeassistant/components/siren/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_AVAILABLE_TONES - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_AVAILABLE_TONES} diff --git a/homeassistant/components/sun/__init__.py b/homeassistant/components/sun/__init__.py index de1c545739f3f0..5bb105f8123c24 100644 --- a/homeassistant/components/sun/__init__.py +++ b/homeassistant/components/sun/__init__.py @@ -17,9 +17,6 @@ from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, event from homeassistant.helpers.entity import Entity -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) from homeassistant.helpers.sun import ( get_astral_location, get_location_astral_event_next, @@ -97,9 +94,6 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up from a config entry.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) hass.data[DOMAIN] = Sun(hass) await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR]) return True @@ -119,6 +113,20 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: class Sun(Entity): """Representation of the Sun.""" + _unrecorded_attributes = frozenset( + { + STATE_ATTR_AZIMUTH, + STATE_ATTR_ELEVATION, + STATE_ATTR_RISING, + STATE_ATTR_NEXT_DAWN, + STATE_ATTR_NEXT_DUSK, + STATE_ATTR_NEXT_MIDNIGHT, + STATE_ATTR_NEXT_NOON, + STATE_ATTR_NEXT_RISING, + STATE_ATTR_NEXT_SETTING, + } + ) + _attr_name = "Sun" entity_id = ENTITY_ID # This entity is legacy and does not have a platform. @@ -143,6 +151,12 @@ def __init__(self, hass: HomeAssistant) -> None: self.hass = hass self.phase: str | None = None + # This is normally done by async_internal_added_to_hass which is not called + # for sun because sun has no platform + self._state_info = { + "unrecorded_attributes": self._Entity__combined_unrecorded_attributes # type: ignore[attr-defined] + } + self._config_listener: CALLBACK_TYPE | None = None self._update_events_listener: CALLBACK_TYPE | None = None self._update_sun_position_listener: CALLBACK_TYPE | None = None diff --git a/homeassistant/components/sun/recorder.py b/homeassistant/components/sun/recorder.py deleted file mode 100644 index 710d7ff45594da..00000000000000 --- a/homeassistant/components/sun/recorder.py +++ /dev/null @@ -1,32 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ( - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - STATE_ATTR_RISING, -) - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude sun attributes from being recorded in the database.""" - return { - STATE_ATTR_AZIMUTH, - STATE_ATTR_ELEVATION, - STATE_ATTR_RISING, - STATE_ATTR_NEXT_DAWN, - STATE_ATTR_NEXT_DUSK, - STATE_ATTR_NEXT_MIDNIGHT, - STATE_ATTR_NEXT_NOON, - STATE_ATTR_NEXT_RISING, - STATE_ATTR_NEXT_SETTING, - } diff --git a/homeassistant/components/text/__init__.py b/homeassistant/components/text/__init__.py index 4182b177bf6db0..acc5f62a0cc57d 100644 --- a/homeassistant/components/text/__init__.py +++ b/homeassistant/components/text/__init__.py @@ -111,6 +111,10 @@ class TextEntityDescription(EntityDescription): class TextEntity(Entity): """Representation of a Text entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} + ) + entity_description: TextEntityDescription _attr_mode: TextMode _attr_native_value: str | None diff --git a/homeassistant/components/text/recorder.py b/homeassistant/components/text/recorder.py deleted file mode 100644 index 09642eb3079d01..00000000000000 --- a/homeassistant/components/text/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_MAX, ATTR_MIN, ATTR_MODE, ATTR_PATTERN} diff --git a/homeassistant/components/trafikverket_camera/__init__.py b/homeassistant/components/trafikverket_camera/__init__.py index dfac8416c49a1e..5575f32788a975 100644 --- a/homeassistant/components/trafikverket_camera/__init__.py +++ b/homeassistant/components/trafikverket_camera/__init__.py @@ -4,10 +4,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import config_validation as cv -from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, -) -from homeassistant.helpers.typing import ConfigType from .const import DOMAIN, PLATFORMS from .coordinator import TVDataUpdateCoordinator @@ -15,14 +11,6 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up trafikverket_camera.""" - # Process integration platforms right away since - # we will create entities before firing EVENT_COMPONENT_LOADED - await async_process_integration_platform_for_component(hass, DOMAIN) - return True - - async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Trafikverket Camera from a config entry.""" diff --git a/homeassistant/components/trafikverket_camera/camera.py b/homeassistant/components/trafikverket_camera/camera.py index a7da3db1433f4d..808d687a13123f 100644 --- a/homeassistant/components/trafikverket_camera/camera.py +++ b/homeassistant/components/trafikverket_camera/camera.py @@ -37,6 +37,8 @@ async def async_setup_entry( class TVCamera(TrafikverketCameraEntity, Camera): """Implement Trafikverket camera.""" + _unrecorded_attributes = frozenset({ATTR_DESCRIPTION, ATTR_LOCATION}) + _attr_name = None _attr_translation_key = "tv_camera" coordinator: TVDataUpdateCoordinator diff --git a/homeassistant/components/trafikverket_camera/recorder.py b/homeassistant/components/trafikverket_camera/recorder.py deleted file mode 100644 index b6b608749ad172..00000000000000 --- a/homeassistant/components/trafikverket_camera/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_LOCATION -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_DESCRIPTION - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude description and location from being recorded in the database.""" - return {ATTR_DESCRIPTION, ATTR_LOCATION} diff --git a/homeassistant/components/unifiprotect/entity.py b/homeassistant/components/unifiprotect/entity.py index d42e611be7ed1d..28149d349c9aeb 100644 --- a/homeassistant/components/unifiprotect/entity.py +++ b/homeassistant/components/unifiprotect/entity.py @@ -311,6 +311,8 @@ def _async_update_device_from_protect(self, device: ProtectModelWithId) -> None: class EventEntityMixin(ProtectDeviceEntity): """Adds motion event attributes to sensor.""" + _unrecorded_attributes = frozenset({ATTR_EVENT_ID, ATTR_EVENT_SCORE}) + entity_description: ProtectEventMixin def __init__( diff --git a/homeassistant/components/unifiprotect/recorder.py b/homeassistant/components/unifiprotect/recorder.py deleted file mode 100644 index 6603a0543f881a..00000000000000 --- a/homeassistant/components/unifiprotect/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_EVENT_ID, ATTR_EVENT_SCORE - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude event_id and event_score from being recorded in the database.""" - return {ATTR_EVENT_ID, ATTR_EVENT_SCORE} diff --git a/homeassistant/components/update/__init__.py b/homeassistant/components/update/__init__.py index e23032e24fe75d..c9496ce8f7bf77 100644 --- a/homeassistant/components/update/__init__.py +++ b/homeassistant/components/update/__init__.py @@ -13,7 +13,7 @@ from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory +from homeassistant.const import ATTR_ENTITY_PICTURE, STATE_OFF, STATE_ON, EntityCategory from homeassistant.core import HomeAssistant, ServiceCall from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv @@ -192,6 +192,10 @@ def _version_is_newer(latest_version: str, installed_version: str) -> bool: class UpdateEntity(RestoreEntity): """Representation of an update entity.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} + ) + entity_description: UpdateEntityDescription _attr_auto_update: bool = False _attr_installed_version: str | None = None diff --git a/homeassistant/components/update/recorder.py b/homeassistant/components/update/recorder.py deleted file mode 100644 index 408937c4f3159e..00000000000000 --- a/homeassistant/components/update/recorder.py +++ /dev/null @@ -1,13 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.const import ATTR_ENTITY_PICTURE -from homeassistant.core import HomeAssistant, callback - -from .const import ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude large and chatty update attributes from being recorded.""" - return {ATTR_ENTITY_PICTURE, ATTR_IN_PROGRESS, ATTR_RELEASE_SUMMARY} diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 8285e1d76d1e66..68d50d1c2fc984 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -228,6 +228,8 @@ class _BaseVacuum(Entity): Contains common properties and functions for all vacuum devices. """ + _entity_component_unrecorded_attributes = frozenset({ATTR_FAN_SPEED_LIST}) + _attr_battery_icon: str _attr_battery_level: int | None = None _attr_fan_speed: str | None = None diff --git a/homeassistant/components/vacuum/recorder.py b/homeassistant/components/vacuum/recorder.py deleted file mode 100644 index 7dc7e9e0408feb..00000000000000 --- a/homeassistant/components/vacuum/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FAN_SPEED_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_FAN_SPEED_LIST} diff --git a/homeassistant/components/water_heater/__init__.py b/homeassistant/components/water_heater/__init__.py index b31d1306c55943..9e796092f6a04a 100644 --- a/homeassistant/components/water_heater/__init__.py +++ b/homeassistant/components/water_heater/__init__.py @@ -164,6 +164,10 @@ class WaterHeaterEntityEntityDescription(EntityDescription): class WaterHeaterEntity(Entity): """Base class for water heater entities.""" + _entity_component_unrecorded_attributes = frozenset( + {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} + ) + entity_description: WaterHeaterEntityEntityDescription _attr_current_operation: str | None = None _attr_current_temperature: float | None = None diff --git a/homeassistant/components/water_heater/recorder.py b/homeassistant/components/water_heater/recorder.py deleted file mode 100644 index d76b96936fafa1..00000000000000 --- a/homeassistant/components/water_heater/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_OPERATION_LIST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude static attributes from being recorded in the database.""" - return {ATTR_OPERATION_LIST, ATTR_MIN_TEMP, ATTR_MAX_TEMP} diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index 0d72dbb825e9df..4ec9ea91f89b84 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -264,6 +264,8 @@ def __post_init__(self, *args: Any, **kwargs: Any) -> None: class WeatherEntity(Entity, PostInit): """ABC for weather data.""" + _entity_component_unrecorded_attributes = frozenset({ATTR_FORECAST}) + entity_description: WeatherEntityDescription _attr_condition: str | None = None # _attr_forecast is deprecated, implement async_forecast_daily, diff --git a/homeassistant/components/weather/recorder.py b/homeassistant/components/weather/recorder.py deleted file mode 100644 index 1c887ea52020b9..00000000000000 --- a/homeassistant/components/weather/recorder.py +++ /dev/null @@ -1,12 +0,0 @@ -"""Integration platform for recorder.""" -from __future__ import annotations - -from homeassistant.core import HomeAssistant, callback - -from . import ATTR_FORECAST - - -@callback -def exclude_attributes(hass: HomeAssistant) -> set[str]: - """Exclude (often large) forecasts from being recorded in the database.""" - return {ATTR_FORECAST} From c170babba62d746f1dbf7ffaf42b56eea2cbcc4a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pedro=20Janu=C3=A1rio?= Date: Thu, 21 Sep 2023 14:18:55 +0100 Subject: [PATCH 634/640] Add ecoforest integration (#100647) * Add ecoforest integration * fix file title * remove host default from schema, hints will be given in the documentation * moved input validation to async_step_user * ensure we can receive device data while doing entry setup * remove unecessary check before unique id is set * added shorter syntax for async create entry Co-authored-by: Joost Lekkerkerker * use variable to set unique id Co-authored-by: Joost Lekkerkerker * Use _attr_has_entity_name from base entity Co-authored-by: Joost Lekkerkerker * remove unecessary comments in coordinator * use shorthand for device information * remove empty objects from manifest * remove unecessary flag Co-authored-by: Joost Lekkerkerker * use _async_abort_entries_match to ensure device is not duplicated * remove unecessary host attr * fixed coordinator host attr to be used by entities to identify device * remove unecessary assert * use default device class temperature trasnlation key * reuse base entity description * use device serial number as identifier * remove unused code * Improve logging message Co-authored-by: Joost Lekkerkerker * Remove unused errors Co-authored-by: Joost Lekkerkerker * Raise a generic update failed Co-authored-by: Joost Lekkerkerker * use coordinator directly Co-authored-by: Joost Lekkerkerker * No need to check for serial number Co-authored-by: Joost Lekkerkerker * rename variable Co-authored-by: Joost Lekkerkerker * use renamed variable Co-authored-by: Joost Lekkerkerker * improve assertion Co-authored-by: Joost Lekkerkerker * use serial number in entity unique id Co-authored-by: Joost Lekkerkerker * raise config entry not ready on setup when error in connection * improve test readability * Improve python syntax Co-authored-by: Joost Lekkerkerker * abort when device already configured with same serial number * improve tests * fix test name * use coordinator data Co-authored-by: Joost Lekkerkerker * improve asserts Co-authored-by: Joost Lekkerkerker * fix ci * improve error handling --------- Co-authored-by: Joost Lekkerkerker --- .coveragerc | 4 + CODEOWNERS | 2 + .../components/ecoforest/__init__.py | 59 +++++++++ .../components/ecoforest/config_flow.py | 63 ++++++++++ homeassistant/components/ecoforest/const.py | 8 ++ .../components/ecoforest/coordinator.py | 39 ++++++ homeassistant/components/ecoforest/entity.py | 42 +++++++ .../components/ecoforest/manifest.json | 9 ++ homeassistant/components/ecoforest/sensor.py | 72 +++++++++++ .../components/ecoforest/strings.json | 21 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ecoforest/__init__.py | 1 + tests/components/ecoforest/conftest.py | 73 +++++++++++ .../components/ecoforest/test_config_flow.py | 115 ++++++++++++++++++ 17 files changed, 521 insertions(+) create mode 100644 homeassistant/components/ecoforest/__init__.py create mode 100644 homeassistant/components/ecoforest/config_flow.py create mode 100644 homeassistant/components/ecoforest/const.py create mode 100644 homeassistant/components/ecoforest/coordinator.py create mode 100644 homeassistant/components/ecoforest/entity.py create mode 100644 homeassistant/components/ecoforest/manifest.json create mode 100644 homeassistant/components/ecoforest/sensor.py create mode 100644 homeassistant/components/ecoforest/strings.json create mode 100644 tests/components/ecoforest/__init__.py create mode 100644 tests/components/ecoforest/conftest.py create mode 100644 tests/components/ecoforest/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index ac08240fd0f38e..5cdb7ec1a14be7 100644 --- a/.coveragerc +++ b/.coveragerc @@ -258,6 +258,10 @@ omit = homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py + homeassistant/components/ecoforest/__init__.py + homeassistant/components/ecoforest/coordinator.py + homeassistant/components/ecoforest/entity.py + homeassistant/components/ecoforest/sensor.py homeassistant/components/econet/__init__.py homeassistant/components/econet/binary_sensor.py homeassistant/components/econet/climate.py diff --git a/CODEOWNERS b/CODEOWNERS index 5bd97369ef517c..4fdf8845fe9606 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -309,6 +309,8 @@ build.json @home-assistant/supervisor /tests/components/easyenergy/ @klaasnicolaas /homeassistant/components/ecobee/ @marthoc @marcolivierarsenault /tests/components/ecobee/ @marthoc @marcolivierarsenault +/homeassistant/components/ecoforest/ @pjanuario +/tests/components/ecoforest/ @pjanuario /homeassistant/components/econet/ @vangorra @w1ll1am23 /tests/components/econet/ @vangorra @w1ll1am23 /homeassistant/components/ecovacs/ @OverloadUT @mib1185 diff --git a/homeassistant/components/ecoforest/__init__.py b/homeassistant/components/ecoforest/__init__.py new file mode 100644 index 00000000000000..cc5575248fe83b --- /dev/null +++ b/homeassistant/components/ecoforest/__init__.py @@ -0,0 +1,59 @@ +"""The Ecoforest integration.""" +from __future__ import annotations + +import logging + +import httpx +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import ( + EcoforestAuthenticationRequired, + EcoforestConnectionError, +) + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator + +PLATFORMS: list[Platform] = [Platform.SENSOR] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Ecoforest from a config entry.""" + + host = entry.data[CONF_HOST] + auth = httpx.BasicAuth(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + api = EcoforestApi(host, auth) + + try: + device = await api.get() + _LOGGER.debug("Ecoforest: %s", device) + except EcoforestAuthenticationRequired: + _LOGGER.error("Authentication on device %s failed", host) + return False + except EcoforestConnectionError as err: + _LOGGER.error("Error communicating with device %s", host) + raise ConfigEntryNotReady from err + + coordinator = EcoforestCoordinator(hass, api) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + 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/ecoforest/config_flow.py b/homeassistant/components/ecoforest/config_flow.py new file mode 100644 index 00000000000000..0afc46c23708bb --- /dev/null +++ b/homeassistant/components/ecoforest/config_flow.py @@ -0,0 +1,63 @@ +"""Config flow for Ecoforest integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from httpx import BasicAuth +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN, MANUFACTURER + +_LOGGER = logging.getLogger(__name__) + +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Ecoforest.""" + + 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: + try: + api = EcoforestApi( + user_input[CONF_HOST], + BasicAuth(user_input[CONF_USERNAME], user_input[CONF_PASSWORD]), + ) + device = await api.get() + except EcoforestAuthenticationRequired: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "cannot_connect" + else: + await self.async_set_unique_id(device.serial_number) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=f"{MANUFACTURER} {device.serial_number}", data=user_input + ) + + return self.async_show_form( + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA, + errors=errors, + ) diff --git a/homeassistant/components/ecoforest/const.py b/homeassistant/components/ecoforest/const.py new file mode 100644 index 00000000000000..8f8bbdcb45a46a --- /dev/null +++ b/homeassistant/components/ecoforest/const.py @@ -0,0 +1,8 @@ +"""Constants for the Ecoforest integration.""" + +from datetime import timedelta + +DOMAIN = "ecoforest" +MANUFACTURER = "Ecoforest" + +POLLING_INTERVAL = timedelta(seconds=30) diff --git a/homeassistant/components/ecoforest/coordinator.py b/homeassistant/components/ecoforest/coordinator.py new file mode 100644 index 00000000000000..b44ccc850ce423 --- /dev/null +++ b/homeassistant/components/ecoforest/coordinator.py @@ -0,0 +1,39 @@ +"""The ecoforest coordinator.""" + + +import logging + +from pyecoforest.api import EcoforestApi +from pyecoforest.exceptions import EcoforestError +from pyecoforest.models.device import Device + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import POLLING_INTERVAL + +_LOGGER = logging.getLogger(__name__) + + +class EcoforestCoordinator(DataUpdateCoordinator[Device]): + """DataUpdateCoordinator to gather data from ecoforest device.""" + + def __init__(self, hass: HomeAssistant, api: EcoforestApi) -> None: + """Initialize DataUpdateCoordinator.""" + + super().__init__( + hass, + _LOGGER, + name="ecoforest", + update_interval=POLLING_INTERVAL, + ) + self.api = api + + async def _async_update_data(self) -> Device: + """Fetch all device and sensor data from api.""" + try: + data = await self.api.get() + _LOGGER.debug("Ecoforest data: %s", data) + return data + except EcoforestError as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err diff --git a/homeassistant/components/ecoforest/entity.py b/homeassistant/components/ecoforest/entity.py new file mode 100644 index 00000000000000..901ed1bf4bf7b7 --- /dev/null +++ b/homeassistant/components/ecoforest/entity.py @@ -0,0 +1,42 @@ +"""Base Entity for Ecoforest.""" +from __future__ import annotations + +from pyecoforest.models.device import Device + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import EntityDescription +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, MANUFACTURER +from .coordinator import EcoforestCoordinator + + +class EcoforestEntity(CoordinatorEntity[EcoforestCoordinator]): + """Common Ecoforest entity using CoordinatorEntity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: EcoforestCoordinator, + description: EntityDescription, + ) -> None: + """Initialize device information.""" + self.entity_description = description + self._attr_unique_id = f"{coordinator.data.serial_number}_{description.key}" + + super().__init__(coordinator) + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, coordinator.data.serial_number)}, + name=MANUFACTURER, + model=coordinator.data.model_name, + sw_version=coordinator.data.firmware, + manufacturer=MANUFACTURER, + ) + + @property + def data(self) -> Device: + """Return ecoforest data.""" + assert self.coordinator.data + return self.coordinator.data diff --git a/homeassistant/components/ecoforest/manifest.json b/homeassistant/components/ecoforest/manifest.json new file mode 100644 index 00000000000000..518f4d97a04eb4 --- /dev/null +++ b/homeassistant/components/ecoforest/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ecoforest", + "name": "Ecoforest", + "codeowners": ["@pjanuario"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ecoforest", + "iot_class": "local_polling", + "requirements": ["pyecoforest==0.3.0"] +} diff --git a/homeassistant/components/ecoforest/sensor.py b/homeassistant/components/ecoforest/sensor.py new file mode 100644 index 00000000000000..bba0a36037541b --- /dev/null +++ b/homeassistant/components/ecoforest/sensor.py @@ -0,0 +1,72 @@ +"""Support for Ecoforest sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +import logging + +from pyecoforest.models.device import Device + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import UnitOfTemperature +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DOMAIN +from .coordinator import EcoforestCoordinator +from .entity import EcoforestEntity + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class EcoforestRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[Device], float | None] + + +@dataclass +class EcoforestSensorEntityDescription( + SensorEntityDescription, EcoforestRequiredKeysMixin +): + """Describes Ecoforest sensor entity.""" + + +SENSOR_TYPES: tuple[EcoforestSensorEntityDescription, ...] = ( + EcoforestSensorEntityDescription( + key="temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + value_fn=lambda data: data.environment_temperature, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Ecoforest sensor platform.""" + coordinator: EcoforestCoordinator = hass.data[DOMAIN][entry.entry_id] + + entities = [ + EcoforestSensor(coordinator, description) for description in SENSOR_TYPES + ] + + async_add_entities(entities) + + +class EcoforestSensor(SensorEntity, EcoforestEntity): + """Representation of an Ecoforest sensor.""" + + entity_description: EcoforestSensorEntityDescription + + @property + def native_value(self) -> float | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.data) diff --git a/homeassistant/components/ecoforest/strings.json b/homeassistant/components/ecoforest/strings.json new file mode 100644 index 00000000000000..d6e3212b4eab64 --- /dev/null +++ b/homeassistant/components/ecoforest/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "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 3f37f3a19dfcb4..54089723e21809 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -114,6 +114,7 @@ "eafm", "easyenergy", "ecobee", + "ecoforest", "econet", "ecowitt", "edl21", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 8c7defb696925f..aac00cdd0d8eaa 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1311,6 +1311,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "ecoforest": { + "name": "Ecoforest", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "econet": { "name": "Rheem EcoNet Products", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index c059d20cbd5dbd..1367844c41831b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1668,6 +1668,9 @@ pydroid-ipcam==2.0.0 # homeassistant.components.ebox pyebox==1.1.4 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dbd433aa4c00ba..cf76db0b1b6e51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1244,6 +1244,9 @@ pydiscovergy==2.0.3 # homeassistant.components.android_ip_webcam pydroid-ipcam==2.0.0 +# homeassistant.components.ecoforest +pyecoforest==0.3.0 + # homeassistant.components.econet pyeconet==0.1.20 diff --git a/tests/components/ecoforest/__init__.py b/tests/components/ecoforest/__init__.py new file mode 100644 index 00000000000000..031cba659d2d50 --- /dev/null +++ b/tests/components/ecoforest/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ecoforest integration.""" diff --git a/tests/components/ecoforest/conftest.py b/tests/components/ecoforest/conftest.py new file mode 100644 index 00000000000000..09860546c1552c --- /dev/null +++ b/tests/components/ecoforest/conftest.py @@ -0,0 +1,73 @@ +"""Common fixtures for the Ecoforest tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, Mock, patch + +from pyecoforest.models.device import Alarm, Device, OperationMode, State +import pytest + +from homeassistant.components.ecoforest import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ecoforest.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture(name="config") +def config_fixture(): + """Define a config entry data fixture.""" + return { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + + +@pytest.fixture(name="serial_number") +def serial_number_fixture(): + """Define a serial number fixture.""" + return "1234" + + +@pytest.fixture(name="mock_device") +def mock_device_fixture(serial_number): + """Define a mocked Ecoforest device fixture.""" + mock = Mock(spec=Device) + mock.model = "model-version" + mock.model_name = "model-name" + mock.firmware = "firmware-version" + mock.serial_number = serial_number + mock.operation_mode = OperationMode.POWER + mock.on = False + mock.state = State.OFF + mock.power = 3 + mock.temperature = 21.5 + mock.alarm = Alarm.PELLETS + mock.alarm_code = "A099" + mock.environment_temperature = 23.5 + mock.cpu_temperature = 36.1 + mock.gas_temperature = 40.2 + mock.ntc_temperature = 24.2 + return mock + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass: HomeAssistant, config, serial_number): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + entry_id="45a36e55aaddb2007c5f6602e0c38e72", + title=f"Ecoforest {serial_number}", + unique_id=serial_number, + data=config, + ) + entry.add_to_hass(hass) + return entry diff --git a/tests/components/ecoforest/test_config_flow.py b/tests/components/ecoforest/test_config_flow.py new file mode 100644 index 00000000000000..302cbe76fa93ff --- /dev/null +++ b/tests/components/ecoforest/test_config_flow.py @@ -0,0 +1,115 @@ +"""Test the Ecoforest config flow.""" +from unittest.mock import AsyncMock, patch + +from pyecoforest.exceptions import EcoforestAuthenticationRequired +import pytest + +from homeassistant import config_entries +from homeassistant.components.ecoforest.const import DOMAIN +from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form( + hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_device, config +) -> 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( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert "result" in result + assert result["result"].unique_id == "1234" + assert result["title"] == "Ecoforest 1234" + assert result["data"] == { + CONF_HOST: "1.1.1.1", + CONF_USERNAME: "test-username", + CONF_PASSWORD: "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_device_already_configured( + hass: HomeAssistant, mock_setup_entry: AsyncMock, config_entry, mock_device, config +) -> None: + """Test device already exists.""" + 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( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("error", "message"), + [ + ( + EcoforestAuthenticationRequired("401"), + "invalid_auth", + ), + ( + Exception("Something wrong"), + "cannot_connect", + ), + ], +) +async def test_flow_fails( + hass: HomeAssistant, error: Exception, message: str, mock_device, config +) -> None: + """Test we handle failed flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "pyecoforest.api.EcoforestApi.get", + side_effect=error, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": message} + + with patch( + "pyecoforest.api.EcoforestApi.get", + return_value=mock_device, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + config, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY From a08b74c550fc2e615a234b2b7add1f915ae6ea2f Mon Sep 17 00:00:00 2001 From: Jan-Philipp Benecke Date: Thu, 21 Sep 2023 15:20:58 +0200 Subject: [PATCH 635/640] Move coolmaster coordinator to its own file (#100425) --- .coveragerc | 1 + .../components/coolmaster/__init__.py | 29 +---------------- .../components/coolmaster/coordinator.py | 31 +++++++++++++++++++ 3 files changed, 33 insertions(+), 28 deletions(-) create mode 100644 homeassistant/components/coolmaster/coordinator.py diff --git a/.coveragerc b/.coveragerc index 5cdb7ec1a14be7..66f7185ee5534b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -182,6 +182,7 @@ omit = homeassistant/components/control4/__init__.py homeassistant/components/control4/director_utils.py homeassistant/components/control4/light.py + homeassistant/components/coolmaster/coordinator.py homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/crownstone/__init__.py homeassistant/components/crownstone/devices.py diff --git a/homeassistant/components/coolmaster/__init__.py b/homeassistant/components/coolmaster/__init__.py index 289e70e80670d4..eaca8949b14292 100644 --- a/homeassistant/components/coolmaster/__init__.py +++ b/homeassistant/components/coolmaster/__init__.py @@ -1,18 +1,13 @@ """The Coolmaster integration.""" -import logging - from pycoolmasternet_async import CoolMasterNet -from homeassistant.components.climate import SCAN_INTERVAL from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_HOST, CONF_PORT, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import CONF_SWING_SUPPORT, DATA_COORDINATOR, DATA_INFO, DOMAIN - -_LOGGER = logging.getLogger(__name__) +from .coordinator import CoolmasterDataUpdateCoordinator PLATFORMS = [Platform.CLIMATE, Platform.BINARY_SENSOR, Platform.BUTTON, Platform.SENSOR] @@ -60,25 +55,3 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: if unload_ok: hass.data[DOMAIN].pop(entry.entry_id) return unload_ok - - -class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): - """Class to manage fetching Coolmaster data.""" - - def __init__(self, hass, coolmaster): - """Initialize global Coolmaster data updater.""" - self._coolmaster = coolmaster - - super().__init__( - hass, - _LOGGER, - name=DOMAIN, - update_interval=SCAN_INTERVAL, - ) - - async def _async_update_data(self): - """Fetch data from Coolmaster.""" - try: - return await self._coolmaster.status() - except OSError as error: - raise UpdateFailed from error diff --git a/homeassistant/components/coolmaster/coordinator.py b/homeassistant/components/coolmaster/coordinator.py new file mode 100644 index 00000000000000..241f287e297518 --- /dev/null +++ b/homeassistant/components/coolmaster/coordinator.py @@ -0,0 +1,31 @@ +"""DataUpdateCoordinator for coolmaster integration.""" +import logging + +from homeassistant.components.climate import SCAN_INTERVAL +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class CoolmasterDataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching Coolmaster data.""" + + def __init__(self, hass, coolmaster): + """Initialize global Coolmaster data updater.""" + self._coolmaster = coolmaster + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=SCAN_INTERVAL, + ) + + async def _async_update_data(self): + """Fetch data from Coolmaster.""" + try: + return await self._coolmaster.status() + except OSError as error: + raise UpdateFailed from error From 6e0ab35f85304dbcbddfd12991707bc0902a6d7f Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 21 Sep 2023 09:43:17 -0400 Subject: [PATCH 636/640] Add water shortage binary sensor (#100662) --- homeassistant/components/roborock/binary_sensor.py | 8 ++++++++ homeassistant/components/roborock/strings.json | 3 +++ tests/components/roborock/test_binary_sensor.py | 5 ++++- 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/binary_sensor.py b/homeassistant/components/roborock/binary_sensor.py index d61c1ee14b9c7c..320b0fc7c6de8d 100644 --- a/homeassistant/components/roborock/binary_sensor.py +++ b/homeassistant/components/roborock/binary_sensor.py @@ -61,6 +61,14 @@ class RoborockBinarySensorDescription( entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda data: data.status.water_box_status, ), + RoborockBinarySensorDescription( + key="water_shortage", + translation_key="water_shortage", + icon="mdi:water", + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: data.status.water_shortage_status, + ), ] diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 92d53c2e6bd1fd..982aa78518e04b 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -36,6 +36,9 @@ }, "water_box_attached": { "name": "Water box attached" + }, + "water_shortage": { + "name": "Water shortage" } }, "number": { diff --git a/tests/components/roborock/test_binary_sensor.py b/tests/components/roborock/test_binary_sensor.py index 310643355b0ca5..4edf8ff4710001 100644 --- a/tests/components/roborock/test_binary_sensor.py +++ b/tests/components/roborock/test_binary_sensor.py @@ -9,9 +9,12 @@ 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")) == 4 + assert len(hass.states.async_all("binary_sensor")) == 6 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 == "on" ) + assert ( + hass.states.get("binary_sensor.roborock_s7_maxv_water_shortage").state == "off" + ) From e2bfa9f9cd47ecba6558b700806d44546a85d22c Mon Sep 17 00:00:00 2001 From: Luke Lashley Date: Thu, 21 Sep 2023 10:00:15 -0400 Subject: [PATCH 637/640] Add last clean sensors to Roborock (#100661) * Add water shortage binary sensor * add last clean sensors * fix tests * fix tests again * remove accidentally added binary sensor --- homeassistant/components/roborock/sensor.py | 16 ++++++++++++++++ homeassistant/components/roborock/strings.json | 6 ++++++ tests/components/roborock/test_sensor.py | 10 +++++++++- 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/roborock/sensor.py b/homeassistant/components/roborock/sensor.py index fc2fa6a6e40a86..8a18c281d593dc 100644 --- a/homeassistant/components/roborock/sensor.py +++ b/homeassistant/components/roborock/sensor.py @@ -143,6 +143,22 @@ class RoborockSensorDescription( native_unit_of_measurement=PERCENTAGE, device_class=SensorDeviceClass.BATTERY, ), + RoborockSensorDescription( + key="last_clean_start", + translation_key="last_clean_start", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.begin_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), + RoborockSensorDescription( + key="last_clean_end", + translation_key="last_clean_end", + icon="mdi:clock-time-twelve", + value_fn=lambda data: data.last_clean_record.end_datetime, + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.TIMESTAMP, + ), # Only available on some newer models RoborockSensorDescription( key="clean_percent", diff --git a/homeassistant/components/roborock/strings.json b/homeassistant/components/roborock/strings.json index 982aa78518e04b..c46eb81415142e 100644 --- a/homeassistant/components/roborock/strings.json +++ b/homeassistant/components/roborock/strings.json @@ -73,6 +73,12 @@ "mop_drying_remaining_time": { "name": "Mop drying remaining time" }, + "last_clean_start": { + "name": "Last clean begin" + }, + "last_clean_end": { + "name": "Last clean end" + }, "side_brush_time_left": { "name": "Side brush time left" }, diff --git a/tests/components/roborock/test_sensor.py b/tests/components/roborock/test_sensor.py index 0089c9a60bd661..35fcc9478cdd92 100644 --- a/tests/components/roborock/test_sensor.py +++ b/tests/components/roborock/test_sensor.py @@ -14,7 +14,7 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> None: """Test sensors and check test values are correctly set.""" - assert len(hass.states.async_all("sensor")) == 24 + assert len(hass.states.async_all("sensor")) == 28 assert hass.states.get("sensor.roborock_s7_maxv_main_brush_time_left").state == str( MAIN_BRUSH_REPLACE_TIME - 74382 ) @@ -39,3 +39,11 @@ async def test_sensors(hass: HomeAssistant, setup_entry: MockConfigEntry) -> Non assert hass.states.get("sensor.roborock_s7_maxv_vacuum_error").state == "none" assert hass.states.get("sensor.roborock_s7_maxv_battery").state == "100" assert hass.states.get("sensor.roborock_s7_maxv_dock_error").state == "ok" + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_begin").state + == "2023-01-01T03:22:10+00:00" + ) + assert ( + hass.states.get("sensor.roborock_s7_maxv_last_clean_end").state + == "2023-01-01T03:43:58+00:00" + ) From 1c7b3cb2d538470db2bf74716e7bc8c95b8598cc Mon Sep 17 00:00:00 2001 From: c0ffeeca7 <38767475+c0ffeeca7@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:02:39 +0200 Subject: [PATCH 638/640] ZHA multiprotocol detected - fix typo (#100683) --- homeassistant/components/zha/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/zha/strings.json b/homeassistant/components/zha/strings.json index 87738e821ea350..f5bebb1e9635b5 100644 --- a/homeassistant/components/zha/strings.json +++ b/homeassistant/components/zha/strings.json @@ -508,7 +508,7 @@ "issues": { "wrong_silabs_firmware_installed_nabucasa": { "title": "Zigbee radio with multiprotocol firmware detected", - "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n -. Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." + "description": "Your Zigbee radio was previously used with multiprotocol (Zigbee and Thread) and still has multiprotocol firmware installed: ({firmware_type}). \n Option 1: To run your radio exclusively with ZHA, you need to install the Zigbee firmware:\n - Open the documentation by selecting the link under \"Learn More\".\n - Follow the instructions described in the step to flash the Zigbee firmware.\n Option 2: To run your radio with multiprotocol, follow these steps: \n - Go to Settings > System > Hardware, select the device and select Configure. \n - Select the Configure IEEE 802.15.4 radio multiprotocol support option. \n - Select the checkbox and select Submit. \n - Once installed, configure the newly discovered ZHA integration." }, "wrong_silabs_firmware_installed_other": { "title": "[%key:component::zha::issues::wrong_silabs_firmware_installed_nabucasa::title%]", From ab060b86d1597432a9bd35b980180375315660a0 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Thu, 21 Sep 2023 17:06:41 +0200 Subject: [PATCH 639/640] Remove async_process_integration_platform_for_component (#100680) --- homeassistant/helpers/integration_platform.py | 19 +++---------------- tests/helpers/test_integration_platform.py | 12 ------------ 2 files changed, 3 insertions(+), 28 deletions(-) diff --git a/homeassistant/helpers/integration_platform.py b/homeassistant/helpers/integration_platform.py index ddaede44962771..0a9a6efd525648 100644 --- a/homeassistant/helpers/integration_platform.py +++ b/homeassistant/helpers/integration_platform.py @@ -65,23 +65,10 @@ async def _async_process_single_integration_platform_component( ) -async def async_process_integration_platform_for_component( +async def _async_process_integration_platform_for_component( hass: HomeAssistant, component_name: str ) -> None: - """Process integration platforms on demand for a component. - - This function will load the integration platforms - for an integration instead of waiting for the EVENT_COMPONENT_LOADED - event to be fired for the integration. - - When the integration will create entities before - it has finished setting up; call this function to ensure - that the integration platforms are loaded before the entities - are created. - """ - if DATA_INTEGRATION_PLATFORMS not in hass.data: - # There are no integration platforms loaded yet - return + """Process integration platforms for a component.""" integration_platforms: list[IntegrationPlatform] = hass.data[ DATA_INTEGRATION_PLATFORMS ] @@ -116,7 +103,7 @@ async def async_process_integration_platforms( async def _async_component_loaded(event: Event) -> None: """Handle a new component loaded.""" - await async_process_integration_platform_for_component( + await _async_process_integration_platform_for_component( hass, event.data[ATTR_COMPONENT] ) diff --git a/tests/helpers/test_integration_platform.py b/tests/helpers/test_integration_platform.py index 2dfc0742e267cb..ed6edcc3690f09 100644 --- a/tests/helpers/test_integration_platform.py +++ b/tests/helpers/test_integration_platform.py @@ -5,7 +5,6 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.integration_platform import ( - async_process_integration_platform_for_component, async_process_integration_platforms, ) from homeassistant.setup import ATTR_COMPONENT, EVENT_COMPONENT_LOADED @@ -43,17 +42,6 @@ async def _process_platform(hass, domain, platform): assert processed[1][0] == "event" assert processed[1][1] == event_platform - # Verify we only process the platform once if we call it manually - await async_process_integration_platform_for_component(hass, "event") - assert len(processed) == 2 - - -async def test_process_integration_platforms_none_loaded(hass: HomeAssistant) -> None: - """Test processing integrations with none loaded.""" - # Verify we can call async_process_integration_platform_for_component - # when there are none loaded and it does not throw - await async_process_integration_platform_for_component(hass, "any") - async def test_broken_integration( hass: HomeAssistant, caplog: pytest.LogCaptureFixture From e57156dd9c19f48f4e35c63fc76367ffbea5f579 Mon Sep 17 00:00:00 2001 From: jimmyd-be <34766203+jimmyd-be@users.noreply.github.com> Date: Thu, 21 Sep 2023 17:55:30 +0200 Subject: [PATCH 640/640] Add Renson button entity (#99494) Co-authored-by: Robert Resch --- .coveragerc | 1 + homeassistant/components/renson/__init__.py | 1 + homeassistant/components/renson/button.py | 90 +++++++++++++++++++ homeassistant/components/renson/manifest.json | 2 +- homeassistant/components/renson/strings.json | 5 ++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 100 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/renson/button.py diff --git a/.coveragerc b/.coveragerc index 66f7185ee5534b..5b046a7249d011 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1020,6 +1020,7 @@ omit = homeassistant/components/renson/coordinator.py homeassistant/components/renson/entity.py homeassistant/components/renson/sensor.py + homeassistant/components/renson/button.py homeassistant/components/renson/fan.py homeassistant/components/renson/binary_sensor.py homeassistant/components/renson/number.py diff --git a/homeassistant/components/renson/__init__.py b/homeassistant/components/renson/__init__.py index 231e63bfc25491..2a9c13be543636 100644 --- a/homeassistant/components/renson/__init__.py +++ b/homeassistant/components/renson/__init__.py @@ -15,6 +15,7 @@ PLATFORMS = [ Platform.BINARY_SENSOR, + Platform.BUTTON, Platform.FAN, Platform.NUMBER, Platform.SENSOR, diff --git a/homeassistant/components/renson/button.py b/homeassistant/components/renson/button.py new file mode 100644 index 00000000000000..53d995ba792864 --- /dev/null +++ b/homeassistant/components/renson/button.py @@ -0,0 +1,90 @@ +"""Renson ventilation unit buttons.""" +from __future__ import annotations + +from dataclasses import dataclass + +from _collections_abc import Callable +from renson_endura_delta.renson import RensonVentilation + +from homeassistant.components.button import ( + ButtonDeviceClass, + ButtonEntity, + ButtonEntityDescription, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import RensonCoordinator, RensonData +from .const import DOMAIN +from .entity import RensonEntity + + +@dataclass +class RensonButtonEntityDescriptionMixin: + """Action function called on press.""" + + action_fn: Callable[[RensonVentilation], None] + + +@dataclass +class RensonButtonEntityDescription( + ButtonEntityDescription, RensonButtonEntityDescriptionMixin +): + """Class describing Renson button entity.""" + + +ENTITY_DESCRIPTIONS: tuple[RensonButtonEntityDescription, ...] = ( + RensonButtonEntityDescription( + key="sync_time", + entity_category=EntityCategory.CONFIG, + translation_key="sync_time", + action_fn=lambda api: api.sync_time(), + ), + RensonButtonEntityDescription( + key="restart", + device_class=ButtonDeviceClass.RESTART, + entity_category=EntityCategory.CONFIG, + action_fn=lambda api: api.restart_device(), + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the Renson button platform.""" + + data: RensonData = hass.data[DOMAIN][config_entry.entry_id] + + entities = [ + RensonButton(description, data.api, data.coordinator) + for description in ENTITY_DESCRIPTIONS + ] + + async_add_entities(entities) + + +class RensonButton(RensonEntity, ButtonEntity): + """Representation of a Renson actions.""" + + _attr_has_entity_name = True + entity_description: RensonButtonEntityDescription + + def __init__( + self, + description: RensonButtonEntityDescription, + api: RensonVentilation, + coordinator: RensonCoordinator, + ) -> None: + """Initialize class.""" + super().__init__(description.key, api, coordinator) + + self.entity_description = description + + def press(self) -> None: + """Triggers the action.""" + self.entity_description.action_fn(self.api) diff --git a/homeassistant/components/renson/manifest.json b/homeassistant/components/renson/manifest.json index 5ff219cc26c880..1a7f367a9464db 100644 --- a/homeassistant/components/renson/manifest.json +++ b/homeassistant/components/renson/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/renson", "iot_class": "local_polling", - "requirements": ["renson-endura-delta==1.5.0"] + "requirements": ["renson-endura-delta==1.6.0"] } diff --git a/homeassistant/components/renson/strings.json b/homeassistant/components/renson/strings.json index 1a4829c2da9dbe..7099cdf2c4587e 100644 --- a/homeassistant/components/renson/strings.json +++ b/homeassistant/components/renson/strings.json @@ -13,6 +13,11 @@ } }, "entity": { + "button": { + "sync_time": { + "name": "Sync time with device" + } + }, "number": { "filter_change": { "name": "Filter clean/replacement" diff --git a/requirements_all.txt b/requirements_all.txt index 1367844c41831b..545e05a7e8431f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2301,7 +2301,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink reolink-aio==0.7.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf76db0b1b6e51..e04213f0c8e528 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1703,7 +1703,7 @@ regenmaschine==2023.06.0 renault-api==0.2.0 # homeassistant.components.renson -renson-endura-delta==1.5.0 +renson-endura-delta==1.6.0 # homeassistant.components.reolink reolink-aio==0.7.10