From e4f8d1ac6437160baf2512897efdf51df4eccf6e Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Mon, 4 May 2026 18:22:51 +0200 Subject: [PATCH 01/22] Update frontend to 20260429.2 (#169748) --- 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 7d972f54bda8c..ea033f34a39d5 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -21,5 +21,5 @@ "integration_type": "system", "preview_features": { "winter_mode": {} }, "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20260429.1"] + "requirements": ["home-assistant-frontend==20260429.2"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 4516c7027934f..b42ec228e9a12 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -39,7 +39,7 @@ habluetooth==6.1.0 hass-nabucasa==2.2.0 hassil==3.5.0 home-assistant-bluetooth==2.0.0 -home-assistant-frontend==20260429.1 +home-assistant-frontend==20260429.2 home-assistant-intents==2026.3.24 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 53c1ca7d6c079..95f780c2ddcc2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1245,7 +1245,7 @@ hole==0.9.0 holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260429.1 +home-assistant-frontend==20260429.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index edf033752b8f0..2bfdc2425f50f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1109,7 +1109,7 @@ hole==0.9.0 holidays==0.95 # homeassistant.components.frontend -home-assistant-frontend==20260429.1 +home-assistant-frontend==20260429.2 # homeassistant.components.conversation home-assistant-intents==2026.3.24 From 86415c1906661dca63bf5d1fa474f11ff1498b66 Mon Sep 17 00:00:00 2001 From: kw6423 <211177151+kw6423@users.noreply.github.com> Date: Mon, 4 May 2026 18:35:18 +0200 Subject: [PATCH 02/22] OwnTracks: expose message tst as update_timestamp in device_tracker attribute (#165203) Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com> --- homeassistant/components/owntracks/const.py | 1 + homeassistant/components/owntracks/device_tracker.py | 2 +- homeassistant/components/owntracks/messages.py | 7 ++++++- tests/components/owntracks/test_device_tracker.py | 11 +++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/owntracks/const.py b/homeassistant/components/owntracks/const.py index c7caa201ca3df..339295b89d53e 100644 --- a/homeassistant/components/owntracks/const.py +++ b/homeassistant/components/owntracks/const.py @@ -1,3 +1,4 @@ """Constants for OwnTracks.""" DOMAIN = "owntracks" +ATTR_UPDATE_TIMESTAMP = "update_timestamp" diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index 691f7789f37ce..eed146b9a31f7 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity -from . import DOMAIN +from .const import DOMAIN async def async_setup_entry( diff --git a/homeassistant/components/owntracks/messages.py b/homeassistant/components/owntracks/messages.py index 93d079b783d1e..c06dcee1099b8 100644 --- a/homeassistant/components/owntracks/messages.py +++ b/homeassistant/components/owntracks/messages.py @@ -9,8 +9,9 @@ from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import SourceType from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, STATE_HOME -from homeassistant.util import decorator, slugify +from homeassistant.util import decorator, dt as dt_util, slugify +from .const import ATTR_UPDATE_TIMESTAMP from .helper import supports_encryption _LOGGER = logging.getLogger(__name__) @@ -85,6 +86,10 @@ def _parse_see_args(message, subscribe_topic): kwargs["source_type"] = SourceType.GPS if message["t"] == "b": kwargs["source_type"] = SourceType.BLUETOOTH_LE + if "tst" in message: + kwargs["attributes"][ATTR_UPDATE_TIMESTAMP] = dt_util.utc_from_timestamp( + message["tst"] + ) return dev_id, kwargs diff --git a/tests/components/owntracks/test_device_tracker.py b/tests/components/owntracks/test_device_tracker.py index 41565c6b1fd69..04f998352dd8c 100644 --- a/tests/components/owntracks/test_device_tracker.py +++ b/tests/components/owntracks/test_device_tracker.py @@ -13,9 +13,11 @@ from homeassistant.components import owntracks from homeassistant.components.device_tracker.legacy import Device +from homeassistant.components.owntracks.const import ATTR_UPDATE_TIMESTAMP from homeassistant.const import STATE_NOT_HOME 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, async_fire_mqtt_message from tests.typing import ClientSessionGenerator, MqttMockHAClient @@ -392,6 +394,14 @@ def assert_location_source_type(hass: HomeAssistant, source_type: str) -> None: assert state.attributes.get("source_type") == source_type +def assert_location_update_timestamp(hass: HomeAssistant, timestamp: int) -> None: + """Test the assertion of update_timestamp.""" + state = hass.states.get(DEVICE_TRACKER_STATE) + assert state.attributes.get(ATTR_UPDATE_TIMESTAMP) == dt_util.utc_from_timestamp( + timestamp + ) + + def assert_mobile_tracker_state( hass: HomeAssistant, location: str, beacon: str = IBEACON_DEVICE ) -> None: @@ -435,6 +445,7 @@ async def test_location_update(hass: HomeAssistant) -> None: assert_location_source_type(hass, "gps") assert_location_latitude(hass, LOCATION_MESSAGE["lat"]) assert_location_accuracy(hass, LOCATION_MESSAGE["acc"]) + assert_location_update_timestamp(hass, LOCATION_MESSAGE["tst"]) assert_location_state(hass, "outer") From c22edbec30c20dbecad3610d4dafddf26699ab5e Mon Sep 17 00:00:00 2001 From: Cristoforo Cervino <25152680+cristoforocervino@users.noreply.github.com> Date: Mon, 4 May 2026 18:42:21 +0200 Subject: [PATCH 03/22] Add opening/closing state icons to valve domain (#169644) --- homeassistant/components/valve/icons.json | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/valve/icons.json b/homeassistant/components/valve/icons.json index c5bccd46b14b2..eae5903f1b15d 100644 --- a/homeassistant/components/valve/icons.json +++ b/homeassistant/components/valve/icons.json @@ -11,7 +11,9 @@ "_": { "default": "mdi:valve-open", "state": { - "closed": "mdi:valve-closed" + "closed": "mdi:valve-closed", + "closing": "mdi:valve", + "opening": "mdi:valve" } }, "gas": { @@ -20,7 +22,9 @@ "water": { "default": "mdi:valve-open", "state": { - "closed": "mdi:valve-closed" + "closed": "mdi:valve-closed", + "closing": "mdi:valve", + "opening": "mdi:valve" } } }, From dfb8c7edb876f1c84251778a3a104147bba51bd6 Mon Sep 17 00:00:00 2001 From: Petro31 <35082313+Petro31@users.noreply.github.com> Date: Mon, 4 May 2026 13:09:46 -0400 Subject: [PATCH 04/22] Fix uptime template sensor (#169743) --- homeassistant/components/template/sensor.py | 3 +- tests/components/template/test_sensor.py | 173 ++++++-------------- 2 files changed, 52 insertions(+), 124 deletions(-) diff --git a/homeassistant/components/template/sensor.py b/homeassistant/components/template/sensor.py index d17b9cdffc5a6..dc37cfabec773 100644 --- a/homeassistant/components/template/sensor.py +++ b/homeassistant/components/template/sensor.py @@ -191,7 +191,7 @@ def validate_datetime( """Converts the template result into a datetime or date.""" def convert(result: Any) -> datetime | date | None: - if resolve_as == SensorDeviceClass.TIMESTAMP: + if resolve_as in (SensorDeviceClass.TIMESTAMP, SensorDeviceClass.UPTIME): if isinstance(result, datetime): return result @@ -263,6 +263,7 @@ def _validate_state( if result is None or self.device_class not in ( SensorDeviceClass.DATE, SensorDeviceClass.TIMESTAMP, + SensorDeviceClass.UPTIME, ): return result diff --git a/tests/components/template/test_sensor.py b/tests/components/template/test_sensor.py index 4c320d916e65b..74cfda972fcbd 100644 --- a/tests/components/template/test_sensor.py +++ b/tests/components/template/test_sensor.py @@ -1362,93 +1362,6 @@ async def test_trigger_attribute_order( ) -async def test_trigger_entity_device_class_parsing_works(hass: HomeAssistant) -> None: - """Test trigger entity device class parsing works.""" - assert await async_setup_component( - hass, - "template", - { - "template": [ - { - "trigger": {"platform": "event", "event_type": "test_event"}, - "sensor": [ - { - "name": "Date entity", - "state": "{{ now().date() }}", - "device_class": "date", - }, - { - "name": "Timestamp entity", - "state": "{{ now() }}", - "device_class": "timestamp", - }, - ], - }, - ], - }, - ) - - await hass.async_block_till_done() - - # State of timestamp sensors are always in UTC - now = dt_util.utcnow() - - with patch("homeassistant.util.dt.now", return_value=now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - - date_state = hass.states.get("sensor.date_entity") - assert date_state is not None - assert date_state.state == now.date().isoformat() - - ts_state = hass.states.get("sensor.timestamp_entity") - assert ts_state is not None - assert ts_state.state == now.isoformat(timespec="seconds") - - -async def test_trigger_entity_device_class_errors_works(hass: HomeAssistant) -> None: - """Test trigger entity device class errors works.""" - assert await async_setup_component( - hass, - "template", - { - "template": [ - { - "trigger": {"platform": "event", "event_type": "test_event"}, - "sensor": [ - { - "name": "Date entity", - "state": "invalid", - "device_class": "date", - }, - { - "name": "Timestamp entity", - "state": "invalid", - "device_class": "timestamp", - }, - ], - }, - ], - }, - ) - - await hass.async_block_till_done() - - now = dt_util.now() - - with patch("homeassistant.util.dt.now", return_value=now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - - date_state = hass.states.get("sensor.date_entity") - assert date_state is not None - assert date_state.state == STATE_UNKNOWN - - ts_state = hass.states.get("sensor.timestamp_entity") - assert ts_state is not None - assert ts_state.state == STATE_UNKNOWN - - async def test_entity_last_reset_total_increasing( hass: HomeAssistant, caplog: pytest.LogCaptureFixture ) -> None: @@ -1560,46 +1473,60 @@ async def test_invalid_last_reset( assert err in caplog_setup_text or err in caplog.text -async def test_entity_device_class_errors_works(hass: HomeAssistant) -> None: - """Test entity device class errors works.""" - assert await async_setup_component( - hass, - "template", - { - "template": [ - { - "sensor": [ - { - "name": "Date entity", - "state": "invalid", - "device_class": "date", - }, - { - "name": "Timestamp entity", - "state": "invalid", - "device_class": "timestamp", - }, - ], - }, - ], - }, - ) - - await hass.async_block_till_done() +@pytest.mark.parametrize("count", [1]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + "config", + [ + {"device_class": "timestamp"}, + {"device_class": "uptime"}, + ], +) +@pytest.mark.parametrize( + ("state_template", "expected_state"), + [ + ("invalid", STATE_UNKNOWN), + ("{{ now() }}", "2026-05-04T00:00:00+00:00"), + ("{{ now().isoformat() }}", "2026-05-04T00:00:00+00:00"), + ], +) +@pytest.mark.usefixtures("setup_state_sensor") +@pytest.mark.freeze_time("2026-05-04 00:00:00+00:00") +async def test_sensor_datetime_device_classes( + hass: HomeAssistant, expected_state: str +) -> None: + """Test sensor datetime device classes.""" + await async_trigger(hass, TEST_STATE_SENSOR, "anything") - now = dt_util.now() + state = hass.states.get(TEST_SENSOR.entity_id) + assert state is not None + assert state.state == expected_state - with patch("homeassistant.util.dt.now", return_value=now): - hass.bus.async_fire("test_event") - await hass.async_block_till_done() - date_state = hass.states.get("sensor.date_entity") - assert date_state is not None - assert date_state.state == STATE_UNKNOWN +@pytest.mark.parametrize(("count", "config"), [(1, {"device_class": "date"})]) +@pytest.mark.parametrize( + "style", [ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER] +) +@pytest.mark.parametrize( + ("state_template", "expected_state"), + [ + ("invalid", STATE_UNKNOWN), + ("{{ utcnow().date() }}", "2026-05-04"), + ], +) +@pytest.mark.usefixtures("setup_state_sensor") +@pytest.mark.freeze_time("2026-05-04 00:00:00+00:00") +async def test_sensor_date_device_class( + hass: HomeAssistant, expected_state: str +) -> None: + """Test sensor date device class.""" + await async_trigger(hass, TEST_STATE_SENSOR, "anything") - ts_state = hass.states.get("sensor.timestamp_entity") - assert ts_state is not None - assert ts_state.state == STATE_UNKNOWN + state = hass.states.get(TEST_SENSOR.entity_id) + assert state is not None + assert state.state == expected_state @pytest.mark.parametrize(("count", "domain"), [(1, "template")]) From 664354c4fe78a6c180c3f478257441dc5af509f7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 May 2026 19:34:17 +0200 Subject: [PATCH 05/22] Fix config flow validation in Nord Pool (#169751) --- .../components/nordpool/config_flow.py | 2 ++ .../components/nordpool/strings.json | 1 + tests/components/nordpool/test_config_flow.py | 33 +++++++++++++++++++ 3 files changed, 36 insertions(+) diff --git a/homeassistant/components/nordpool/config_flow.py b/homeassistant/components/nordpool/config_flow.py index 17bedf55818ae..3943ea2f69b77 100644 --- a/homeassistant/components/nordpool/config_flow.py +++ b/homeassistant/components/nordpool/config_flow.py @@ -54,6 +54,8 @@ async def test_api(hass: HomeAssistant, user_input: dict[str, Any]) -> dict[str, str]: """Test fetch data from Nord Pool.""" + if not user_input.get(CONF_AREAS): + return {CONF_AREAS: "no_areas"} client = NordPoolClient(async_get_clientsession(hass)) try: await client.async_get_delivery_period( diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 89e99c37908c0..88706a9fbbdc0 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -5,6 +5,7 @@ }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "no_areas": "No area(s) selected", "no_data": "API connected but the response was empty" }, "step": { diff --git a/tests/components/nordpool/test_config_flow.py b/tests/components/nordpool/test_config_flow.py index c5d9d55f1f380..694f7c52b0817 100644 --- a/tests/components/nordpool/test_config_flow.py +++ b/tests/components/nordpool/test_config_flow.py @@ -105,6 +105,39 @@ async def test_cannot_connect( assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") +async def test_missing_areas( + hass: HomeAssistant, + get_client: NordPoolClient, +) -> None: + """Test cannot connect error.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == config_entries.SOURCE_USER + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_CURRENCY: "SEK", + }, + ) + + assert result["errors"] == {CONF_AREAS: "no_areas"} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Nord Pool" + assert result["data"] == {"areas": ["SE3", "SE4"], "currency": "SEK"} + + @pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") async def test_reconfigure( hass: HomeAssistant, From 1140d52735075820f207124406792aed99655f89 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 4 May 2026 18:39:46 +0100 Subject: [PATCH 06/22] Bump pytrydan to 1.0.0 (#169742) --- homeassistant/components/v2c/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/v2c/snapshots/test_diagnostics.ambr | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/v2c/manifest.json b/homeassistant/components/v2c/manifest.json index ea9f3e3579e9d..2cabf8952e199 100644 --- a/homeassistant/components/v2c/manifest.json +++ b/homeassistant/components/v2c/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/v2c", "integration_type": "device", "iot_class": "local_polling", - "requirements": ["pytrydan==0.8.0"] + "requirements": ["pytrydan==1.0.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 95f780c2ddcc2..9e0bc9da8dfab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2736,7 +2736,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==0.8.0 +pytrydan==1.0.0 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2bfdc2425f50f..d7a33c39d2b7b 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2335,7 +2335,7 @@ pytradfri[async]==9.0.1 pytrafikverket==1.1.1 # homeassistant.components.v2c -pytrydan==0.8.0 +pytrydan==1.0.0 # homeassistant.components.uptimerobot pyuptimerobot==25.0.0 diff --git a/tests/components/v2c/snapshots/test_diagnostics.ambr b/tests/components/v2c/snapshots/test_diagnostics.ambr index 780a00acd64be..2ccc3f4f21a98 100644 --- a/tests/components/v2c/snapshots/test_diagnostics.ambr +++ b/tests/components/v2c/snapshots/test_diagnostics.ambr @@ -22,7 +22,7 @@ 'unique_id': 'ABC123', 'version': 1, }), - 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=None, charge_energy=1.8, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", + 'data': "TrydanData(ID='ABC123', charge_state=, ready_state=, charge_power=1500.27, voltage_installation=None, charge_energy=1.8, charge_mode=None, slave_error=, charge_time=4355, house_power=0.0, fv_power=0.0, battery_power=0.0, paused=, locked=, timer=, intensity=6, dynamic=, min_intensity=6, max_intensity=16, pause_dynamic=, light_led=None, logo_led=None, dynamic_power_mode=, contracted_power=4600, firmware_version='2.1.7', SSID=None, IP=None, signal_status=None)", 'host_status': 200, 'raw_data': '{"ID":"ABC123","ChargeState":2,"ReadyState":0,"ChargePower":1500.27,"ChargeEnergy":1.8,"SlaveError":4,"ChargeTime":4355,"HousePower":0.0,"FVPower":0.0,"BatteryPower":0.0,"Paused":0,"Locked":0,"Timer":0,"Intensity":6,"Dynamic":0,"MinIntensity":6,"MaxIntensity":16,"PauseDynamic":0,"FirmwareVersion":"2.1.7","DynamicPowerMode":2,"ContractedPower":4600}', }) From 5858db1cda37f7fe8f68cc9130464f0c2bc68b15 Mon Sep 17 00:00:00 2001 From: Christian Lackas Date: Mon, 4 May 2026 19:56:36 +0200 Subject: [PATCH 07/22] Use all_devices in ViCare diagnostics for completeness (#169429) --- homeassistant/components/vicare/diagnostics.py | 2 +- tests/components/vicare/conftest.py | 17 +++++++++++++++++ .../vicare/snapshots/test_diagnostics.ambr | 12 ++++++++++++ 3 files changed, 30 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/vicare/diagnostics.py b/homeassistant/components/vicare/diagnostics.py index 1f22ce8c1cd7b..0f8258a8209ad 100644 --- a/homeassistant/components/vicare/diagnostics.py +++ b/homeassistant/components/vicare/diagnostics.py @@ -21,7 +21,7 @@ def dump_devices() -> list[dict[str, Any]]: """Dump devices.""" return [ json.loads(device.dump_secure()) - for device in entry.runtime_data.client.devices + for device in entry.runtime_data.client.all_devices ] return { diff --git a/tests/components/vicare/conftest.py b/tests/components/vicare/conftest.py index b343586d54920..26e6f33a42d57 100644 --- a/tests/components/vicare/conftest.py +++ b/tests/components/vicare/conftest.py @@ -43,6 +43,23 @@ def __init__(self, fixtures: list[Fixture]) -> None: "Online", ) ) + # Simulate a device with an unsupported deviceType that PyViCare's + # `devices` filter would drop but should still appear in `all_devices` + # (used by diagnostics). + self.all_devices = [ + *self.devices, + PyViCareDeviceConfig( + MockViCareService( + "installation_unsupported", + "gateway_unsupported", + "device_unsupported", + Fixture(set(), "vicare/dummy-device-no-serial.json"), + ), + "deviceId_unsupported", + "unsupported_model", + "Online", + ), + ] class MockViCareService: diff --git a/tests/components/vicare/snapshots/test_diagnostics.ambr b/tests/components/vicare/snapshots/test_diagnostics.ambr index e0ca73c7c5e43..3844a3f248cb6 100644 --- a/tests/components/vicare/snapshots/test_diagnostics.ambr +++ b/tests/components/vicare/snapshots/test_diagnostics.ambr @@ -4720,6 +4720,18 @@ 'type': None, }), }), + dict({ + 'data': list([ + ]), + 'device': dict({ + 'id': 'deviceId_unsupported', + 'modelId': 'unsupported_model', + 'roles': list([ + ]), + 'status': 'Online', + 'type': None, + }), + }), ]), 'entry': dict({ 'data': dict({ From 2846dcc0352e44637c7bc21069f3c3d8bc1da4d2 Mon Sep 17 00:00:00 2001 From: Leonardo Rivera Date: Mon, 4 May 2026 15:17:41 -0300 Subject: [PATCH 08/22] Add delete service action to OneDrive integration (#168064) Co-authored-by: Josef Zweck Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/onedrive/icons.json | 3 + homeassistant/components/onedrive/services.py | 90 ++++- .../components/onedrive/services.yaml | 13 + .../components/onedrive/strings.json | 26 +- tests/components/onedrive/test_services.py | 314 +++++++++++++++++- 5 files changed, 431 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/onedrive/icons.json b/homeassistant/components/onedrive/icons.json index 66f510b8e8237..a8933728ae028 100644 --- a/homeassistant/components/onedrive/icons.json +++ b/homeassistant/components/onedrive/icons.json @@ -22,6 +22,9 @@ } }, "services": { + "delete": { + "service": "mdi:cloud-remove" + }, "upload": { "service": "mdi:cloud-upload" } diff --git a/homeassistant/components/onedrive/services.py b/homeassistant/components/onedrive/services.py index d88cf185b3943..1693454f38640 100644 --- a/homeassistant/components/onedrive/services.py +++ b/homeassistant/components/onedrive/services.py @@ -2,7 +2,7 @@ import asyncio from dataclasses import asdict -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import cast from onedrive_personal_sdk.exceptions import OneDriveException @@ -19,11 +19,12 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv, service -from .const import DOMAIN +from .const import CONF_DELETE_PERMANENTLY, DOMAIN from .coordinator import OneDriveConfigEntry CONF_CONFIG_ENTRY_ID = "config_entry_id" CONF_DESTINATION_FOLDER = "destination_folder" +CONF_DESTINATION_PATH = "destination_path" UPLOAD_SERVICE = "upload" UPLOAD_SERVICE_SCHEMA = vol.Schema( @@ -33,6 +34,17 @@ vol.Required(CONF_DESTINATION_FOLDER): cv.string, } ) + +DELETE_SERVICE = "delete" +DELETE_SERVICE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CONFIG_ENTRY_ID): cv.string, + vol.Required(CONF_DESTINATION_PATH): vol.All( + cv.ensure_list, vol.Length(min=1), [cv.string] + ), + } +) + CONTENT_SIZE_LIMIT = 250 * 1024 * 1024 @@ -76,6 +88,29 @@ def _read_file_contents( return results +def _raise_invalid_destination_path(destination_path: str) -> None: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="invalid_destination_path", + translation_placeholders={"destination_path": destination_path}, + ) + + +def _validate_destination_path(destination_path: str) -> str: + """Validate and normalize a remote destination path. + + Returns the normalized path or raises HomeAssistantError. + """ + normalized = destination_path.strip("/") + if not normalized: + _raise_invalid_destination_path(destination_path) + parts = PurePosixPath(normalized).parts + for part in parts: + if part == ".." or ":" in part: + _raise_invalid_destination_path(destination_path) + return str(PurePosixPath(normalized)) + + @callback def async_setup_services(hass: HomeAssistant) -> None: """Register OneDrive services.""" @@ -122,6 +157,50 @@ async def async_handle_upload(call: ServiceCall) -> ServiceResponse: return {"files": [asdict(item_result) for item_result in upload_results]} return None + async def async_handle_delete(call: ServiceCall) -> None: + """Delete one or more files from OneDrive.""" + config_entry: OneDriveConfigEntry = service.async_get_config_entry( + hass, DOMAIN, call.data[CONF_CONFIG_ENTRY_ID] + ) + client = config_entry.runtime_data.client + delete_permanently = config_entry.options.get(CONF_DELETE_PERMANENTLY, False) + file_paths = [ + _validate_destination_path(p) + for p in cast(list[str], call.data[CONF_DESTINATION_PATH]) + ] + + try: + approot_id = (await client.get_approot()).id + except OneDriveException as err: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="connection_error", + ) from err + + results = await asyncio.gather( + *[ + client.delete_drive_item( + f"{approot_id}:/{file_path}:", delete_permanently + ) + for file_path in file_paths + ], + return_exceptions=True, + ) + failures: list[tuple[str, OneDriveException]] = [] + for file_path, result in zip(file_paths, results, strict=True): + if isinstance(result, OneDriveException): + failures.append((file_path, result)) + if failures: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="delete_error", + translation_placeholders={ + "paths": ", ".join(f"`{path}`" for path, _ in failures) + }, + ) from ExceptionGroup( + "OneDrive delete errors", [err for _, err in failures] + ) + hass.services.async_register( DOMAIN, UPLOAD_SERVICE, @@ -130,3 +209,10 @@ async def async_handle_upload(call: ServiceCall) -> ServiceResponse: supports_response=SupportsResponse.OPTIONAL, description_placeholders={"example_image_path": "/config/www/image.jpg"}, ) + + hass.services.async_register( + DOMAIN, + DELETE_SERVICE, + async_handle_delete, + schema=DELETE_SERVICE_SCHEMA, + ) diff --git a/homeassistant/components/onedrive/services.yaml b/homeassistant/components/onedrive/services.yaml index fdc934ef9b2cb..d39968d74f081 100644 --- a/homeassistant/components/onedrive/services.yaml +++ b/homeassistant/components/onedrive/services.yaml @@ -14,3 +14,16 @@ upload: required: true selector: text: + +delete: + fields: + config_entry_id: + required: true + selector: + config_entry: + integration: onedrive + destination_path: + required: true + selector: + text: + multiple: true diff --git a/homeassistant/components/onedrive/strings.json b/homeassistant/components/onedrive/strings.json index 1f1aa5ec38924..5ba210929b00d 100644 --- a/homeassistant/components/onedrive/strings.json +++ b/homeassistant/components/onedrive/strings.json @@ -90,9 +90,15 @@ "authentication_failed": { "message": "Authentication failed" }, + "connection_error": { + "message": "[%key:component::onedrive::config::abort::connection_error%]" + }, "create_folder_error": { "message": "Failed to create folder: {message}" }, + "delete_error": { + "message": "Failed to delete from OneDrive: {paths}" + }, "failed_to_get_folder": { "message": "Failed to get {folder} folder" }, @@ -105,6 +111,9 @@ "filenames_do_not_exist": { "message": "The following files do not exist: {filenames}" }, + "invalid_destination_path": { + "message": "Invalid destination path `{destination_path}`: must be non-empty, must not contain `:` or `..` path segments" + }, "no_access_to_path": { "message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`" }, @@ -142,6 +151,21 @@ } }, "services": { + "delete": { + "description": "Deletes one or more files from OneDrive.", + "fields": { + "config_entry_id": { + "description": "The config entry representing the OneDrive you want to delete from.", + "name": "Config entry ID" + }, + "destination_path": { + "description": "One or more paths to files inside the OneDrive app folder (Apps/Home Assistant) to delete.", + "example": "[\"photos/snapshots/image.jpg\", \"photos/snapshots/image2.jpg\"]", + "name": "Destination paths" + } + }, + "name": "Delete files" + }, "upload": { "description": "Uploads one or more files to OneDrive.", "fields": { @@ -150,7 +174,7 @@ "name": "Config entry ID" }, "destination_folder": { - "description": "Folder inside the Home Assistant app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.", + "description": "Folder inside the OneDrive app folder (Apps/Home Assistant) you want to upload the files to. Will be created if it does not exist.", "example": "photos/snapshots", "name": "Destination folder" }, diff --git a/tests/components/onedrive/test_services.py b/tests/components/onedrive/test_services.py index cb0bb8721ed44..5e40ea90a461d 100644 --- a/tests/components/onedrive/test_services.py +++ b/tests/components/onedrive/test_services.py @@ -8,11 +8,14 @@ from onedrive_personal_sdk.exceptions import OneDriveException import pytest +import voluptuous as vol -from homeassistant.components.onedrive.const import DOMAIN +from homeassistant.components.onedrive.const import CONF_DELETE_PERMANENTLY, DOMAIN from homeassistant.components.onedrive.services import ( CONF_CONFIG_ENTRY_ID, CONF_DESTINATION_FOLDER, + CONF_DESTINATION_PATH, + DELETE_SERVICE, UPLOAD_SERVICE, ) from homeassistant.config_entries import ConfigEntryState @@ -25,7 +28,8 @@ from tests.common import MockConfigEntry TEST_FILENAME = "doorbell_snapshot.jpg" -DESINATION_FOLDER = "TestFolder" +TEST_DESTINATION_PATH = "photos/snapshots/image.jpg" +DESTINATION_FOLDER = "TestFolder" @dataclass @@ -83,7 +87,7 @@ async def test_upload_service( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -109,7 +113,7 @@ async def test_upload_service_no_response( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, ) @@ -130,7 +134,7 @@ async def test_upload_service_config_entry_not_found( { CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -156,7 +160,7 @@ async def test_config_entry_not_loaded( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -180,7 +184,7 @@ async def test_path_is_not_allowed( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -201,7 +205,7 @@ async def test_filename_does_not_exist( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -225,7 +229,7 @@ async def test_multiple_filenames_do_not_exist( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: [TEST_FILENAME, second_filename], - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -251,7 +255,7 @@ async def test_upload_service_fails_upload( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -275,7 +279,7 @@ async def test_upload_size_limit( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, @@ -300,8 +304,294 @@ async def test_create_album_failed( { CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, CONF_FILENAME: TEST_FILENAME, - CONF_DESTINATION_FOLDER: DESINATION_FOLDER, + CONF_DESTINATION_FOLDER: DESTINATION_FOLDER, }, blocking=True, return_response=True, ) + + +async def test_delete_service_config_entry_not_found( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test delete service call with a config entry that does not exist.""" + await setup_integration(hass, mock_config_entry) + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: "invalid-config-entry-id", + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + assert err.value.translation_key == "service_config_entry_not_found" + + +async def test_delete_service_config_entry_not_loaded( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test delete service call with a config entry that is not loaded.""" + await setup_integration(hass, mock_config_entry) + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + assert err.value.translation_key == "service_config_entry_not_loaded" + + +async def test_delete_service( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service call removes the remote file.""" + await setup_integration(hass, mock_config_entry) + + assert hass.services.has_service(DOMAIN, DELETE_SERVICE) + + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + + mock_onedrive_client.delete_drive_item.assert_called_once() + call_args = mock_onedrive_client.delete_drive_item.call_args + assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:" + assert call_args.args[1] is False + + +async def test_delete_service_delete_permanently( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service passes delete_permanently=True when option is set.""" + await setup_integration(hass, mock_config_entry) + hass.config_entries.async_update_entry( + mock_config_entry, options={CONF_DELETE_PERMANENTLY: True} + ) + + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + + call_args = mock_onedrive_client.delete_drive_item.call_args + assert call_args.args[0] == f"id:/{TEST_DESTINATION_PATH}:" + assert call_args.args[1] is True + + +async def test_delete_service_multiple_files( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service removes multiple remote files in parallel.""" + await setup_integration(hass, mock_config_entry) + second_path = "photos/snapshots/image2.jpg" + + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path], + }, + blocking=True, + ) + + assert mock_onedrive_client.delete_drive_item.call_count == 2 + called_paths = { + c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list + } + assert called_paths == { + f"id:/{TEST_DESTINATION_PATH}:", + f"id:/{second_path}:", + } + + +async def test_delete_service_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service raises HomeAssistantError on OneDriveException.""" + await setup_integration(hass, mock_config_entry) + mock_onedrive_client.delete_drive_item.side_effect = OneDriveException("api error") + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + assert exc_info.value.translation_key == "delete_error" + assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"] + + +async def test_delete_service_multiple_files_all_fail( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service aggregates errors from multiple failed deletions.""" + await setup_integration(hass, mock_config_entry) + second_path = "photos/snapshots/image2.jpg" + mock_onedrive_client.delete_drive_item.side_effect = [ + OneDriveException("error one"), + OneDriveException("error two"), + ] + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path], + }, + blocking=True, + ) + + assert mock_onedrive_client.delete_drive_item.call_count == 2 + assert isinstance(exc_info.value.__cause__, ExceptionGroup) + assert len(exc_info.value.__cause__.exceptions) == 2 + assert TEST_DESTINATION_PATH in exc_info.value.translation_placeholders["paths"] + assert second_path in exc_info.value.translation_placeholders["paths"] + + +async def test_delete_service_multiple_files_partial_failure( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service attempts all deletions before raising on partial failure.""" + await setup_integration(hass, mock_config_entry) + second_path = "photos/snapshots/image2.jpg" + mock_onedrive_client.delete_drive_item.side_effect = [ + None, + OneDriveException("error two"), + ] + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH, second_path], + }, + blocking=True, + ) + + assert mock_onedrive_client.delete_drive_item.call_count == 2 + called_paths = { + c.args[0] for c in mock_onedrive_client.delete_drive_item.call_args_list + } + assert called_paths == { + f"id:/{TEST_DESTINATION_PATH}:", + f"id:/{second_path}:", + } + assert exc_info.value.translation_key == "delete_error" + assert second_path in exc_info.value.translation_placeholders["paths"] + + +async def test_delete_service_get_approot_fails( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_onedrive_client: MagicMock, +) -> None: + """Test delete service raises HomeAssistantError when get_approot fails.""" + await setup_integration(hass, mock_config_entry) + mock_onedrive_client.get_approot.side_effect = OneDriveException("network error") + + with pytest.raises(HomeAssistantError) as exc_info: + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [TEST_DESTINATION_PATH], + }, + blocking=True, + ) + assert exc_info.value.translation_key == "connection_error" + + +async def test_delete_empty_destination_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test delete service raises when destination_path is an empty list.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(vol.Invalid): + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: [], + }, + blocking=True, + ) + + +@pytest.mark.parametrize( + "bad_path", + [ + "", + "/", + "//", + "photos/../secrets", + "photos/file:name.jpg", + "../escape", + ], +) +async def test_delete_invalid_destination_path( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + bad_path: str, +) -> None: + """Test delete service raises HomeAssistantError for invalid destination paths.""" + await setup_integration(hass, mock_config_entry) + + with pytest.raises(HomeAssistantError, match="Invalid destination path"): + await hass.services.async_call( + DOMAIN, + DELETE_SERVICE, + { + CONF_CONFIG_ENTRY_ID: mock_config_entry.entry_id, + CONF_DESTINATION_PATH: bad_path, + }, + blocking=True, + ) From fcd23353f27813acab9eebb4ca2155ebbf2f1150 Mon Sep 17 00:00:00 2001 From: optimusbasti <52_mangas_chariot@icloud.com> Date: Mon, 4 May 2026 20:23:26 +0200 Subject: [PATCH 09/22] Add set_cover_position_and_tilt service to Overkiz (#169275) Co-authored-by: optimusbasti Co-authored-by: ThomasCZ --- homeassistant/components/overkiz/__init__.py | 16 ++- homeassistant/components/overkiz/cover.py | 32 ++++- homeassistant/components/overkiz/icons.json | 5 + homeassistant/components/overkiz/services.py | 44 +++++++ .../components/overkiz/services.yaml | 23 ++++ homeassistant/components/overkiz/strings.json | 21 +++ tests/components/overkiz/test_cover.py | 120 +++++++++++++++++- 7 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/overkiz/services.py create mode 100644 homeassistant/components/overkiz/services.yaml diff --git a/homeassistant/components/overkiz/__init__.py b/homeassistant/components/overkiz/__init__.py index e7ecd008a1a4d..eeca97dc47562 100644 --- a/homeassistant/components/overkiz/__init__.py +++ b/homeassistant/components/overkiz/__init__.py @@ -28,8 +28,13 @@ ) from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.helpers.typing import ConfigType from .const import ( CONF_API_TYPE, @@ -42,6 +47,9 @@ UPDATE_INTERVAL_LOCAL, ) from .coordinator import OverkizDataUpdateCoordinator +from .services import async_setup_services + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) @dataclass @@ -56,6 +64,12 @@ class HomeAssistantOverkizData: type OverkizDataConfigEntry = ConfigEntry[HomeAssistantOverkizData] +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Overkiz component.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: OverkizDataConfigEntry) -> bool: """Set up Overkiz from a config entry.""" client: OverkizClient | None = None diff --git a/homeassistant/components/overkiz/cover.py b/homeassistant/components/overkiz/cover.py index 92626c19ce84d..9eb3b3d079d8c 100644 --- a/homeassistant/components/overkiz/cover.py +++ b/homeassistant/components/overkiz/cover.py @@ -22,10 +22,11 @@ ) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import OverkizDataConfigEntry -from .const import LOGGER +from .const import DOMAIN, LOGGER from .coordinator import OverkizDataUpdateCoordinator from .entity import OverkizDescriptiveEntity @@ -506,6 +507,35 @@ async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: if command := self.entity_description.set_tilt_position_command: await self.executor.async_execute_command(command, position) + async def async_set_cover_position_and_tilt(self, **kwargs: Any) -> None: + """Move cover and tilt to a specific position simultaneously. + + Exposed as the `overkiz.set_cover_position_and_tilt` service action. Uses the + setClosureAndOrientation command to move slats and closure in a single instruction. + Calling set_cover_position and set_cover_tilt_position sequentially will cause + the motor to stop between commands on some devices (e.g. Somfy + DynamicExteriorVenetianBlind). + """ + if not self.executor.has_command(OverkizCommand.SET_CLOSURE_AND_ORIENTATION): + raise ServiceValidationError( + translation_domain=DOMAIN, + translation_key="unsupported_set_position_and_tilt", + ) + + position = kwargs[ATTR_POSITION] + tilt_position = kwargs[ATTR_TILT_POSITION] + + if self.entity_description.invert_position: + position = 100 - position + if self.entity_description.invert_tilt_position: + tilt_position = 100 - tilt_position + + await self.executor.async_execute_command( + OverkizCommand.SET_CLOSURE_AND_ORIENTATION, + position, + tilt_position, + ) + async def async_open_cover_tilt(self, **kwargs: Any) -> None: """Open the cover tilt.""" if command := self.entity_description.open_tilt_command: diff --git a/homeassistant/components/overkiz/icons.json b/homeassistant/components/overkiz/icons.json index 6e5db404e1723..579155b64f8d6 100644 --- a/homeassistant/components/overkiz/icons.json +++ b/homeassistant/components/overkiz/icons.json @@ -42,5 +42,10 @@ } } } + }, + "services": { + "set_cover_position_and_tilt": { + "service": "mdi:window-shutter-cog" + } } } diff --git a/homeassistant/components/overkiz/services.py b/homeassistant/components/overkiz/services.py new file mode 100644 index 0000000000000..e42cf2b22eaf5 --- /dev/null +++ b/homeassistant/components/overkiz/services.py @@ -0,0 +1,44 @@ +"""Services for the Overkiz integration.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.cover import ( + ATTR_POSITION, + ATTR_TILT_POSITION, + DOMAIN as COVER_DOMAIN, + CoverEntityFeature, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import service + +from .const import DOMAIN + +SERVICE_SET_COVER_POSITION_AND_TILT = "set_cover_position_and_tilt" + +POSITION_MIN = 0 +POSITION_MAX = 100 + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Set up the services for the Overkiz integration.""" + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_SET_COVER_POSITION_AND_TILT, + entity_domain=COVER_DOMAIN, + schema={ + vol.Required(ATTR_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + vol.Required(ATTR_TILT_POSITION): vol.All( + vol.Coerce(int), vol.Range(min=POSITION_MIN, max=POSITION_MAX) + ), + }, + func="async_set_cover_position_and_tilt", + required_features=[ + CoverEntityFeature.SET_POSITION | CoverEntityFeature.SET_TILT_POSITION + ], + ) diff --git a/homeassistant/components/overkiz/services.yaml b/homeassistant/components/overkiz/services.yaml new file mode 100644 index 0000000000000..f51b602d96259 --- /dev/null +++ b/homeassistant/components/overkiz/services.yaml @@ -0,0 +1,23 @@ +set_cover_position_and_tilt: + target: + entity: + integration: overkiz + domain: cover + supported_features: + - - cover.CoverEntityFeature.SET_POSITION + - cover.CoverEntityFeature.SET_TILT_POSITION + fields: + position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" + tilt_position: + required: true + selector: + number: + min: 0 + max: 100 + unit_of_measurement: "%" diff --git a/homeassistant/components/overkiz/strings.json b/homeassistant/components/overkiz/strings.json index 7e55067e80b7e..9da79f9007326 100644 --- a/homeassistant/components/overkiz/strings.json +++ b/homeassistant/components/overkiz/strings.json @@ -179,5 +179,26 @@ } } } + }, + "exceptions": { + "unsupported_set_position_and_tilt": { + "message": "This device does not support setting position and tilt simultaneously." + } + }, + "services": { + "set_cover_position_and_tilt": { + "description": "Moves the cover and tilt to the target position simultaneously, preventing the motor from stopping between movements.", + "fields": { + "position": { + "description": "Target vertical position. 0 means closed, 100 means fully open.", + "name": "Position" + }, + "tilt_position": { + "description": "Target tilt position. 0 means closed, 100 means fully open.", + "name": "Tilt position" + } + }, + "name": "Set cover position and tilt" + } } } diff --git a/tests/components/overkiz/test_cover.py b/tests/components/overkiz/test_cover.py index 46f594b9fdf96..21bcc495ce213 100644 --- a/tests/components/overkiz/test_cover.py +++ b/tests/components/overkiz/test_cover.py @@ -29,6 +29,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from .conftest import FixtureDevice, MockOverkizClient, SetupOverkizIntegration @@ -75,7 +76,7 @@ DYNAMIC_EXTERIOR_VENETIAN_BLIND = FixtureDevice( "setup/local_somfy_tahoma_switch_europe.json", "io://1234-5678-6508/4877511", - "cover.dining_room_blinds", + "cover.office_blinds", ) # Device with ClosureState=124 POSITIONABLE_ROLLER_SHUTTER_UNO = FixtureDevice( @@ -800,3 +801,120 @@ async def test_low_speed_cover_open_close( command_name="setClosureAndLinearSpeed", parameters=[100, OverkizCommandParam.LOWSPEED], ) + + +async def test_set_cover_position_and_tilt_service_is_registered( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, +) -> None: + """The overkiz.set_cover_position_and_tilt service must be registered.""" + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + assert hass.services.has_service("overkiz", "set_cover_position_and_tilt") + + +async def test_set_cover_position_and_tilt_executes_single_command( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """Position+tilt must be sent as one atomic setClosureAndOrientation call. + + Replaces two sequential set_cover_position + set_cover_tilt_position calls, + which cause Somfy motors to stop mid-movement between commands. + """ + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: 30, + ATTR_TILT_POSITION: 80, + }, + blocking=True, + ) + + # Home Assistant position 30 -> Overkiz closure 70 (inverted), + # tilt 80 -> orientation 20 (inverted). + assert_command_call( + mock_client, + device_url=DYNAMIC_EXTERIOR_VENETIAN_BLIND.device_url, + command_name="setClosureAndOrientation", + parameters=[70, 20], + ) + + +@pytest.mark.parametrize( + ("position", "tilt_position", "expected_parameters"), + [ + (0, 100, [100, 0]), + (100, 0, [0, 100]), + (50, 50, [50, 50]), + ], + ids=["closed-tilt-open", "open-tilt-closed", "midpoint"], +) +async def test_set_cover_position_and_tilt_inverts_boundaries( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, + position: int, + tilt_position: int, + expected_parameters: list[int], +) -> None: + """Boundary and midpoint values must invert consistently.""" + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: position, + ATTR_TILT_POSITION: tilt_position, + }, + blocking=True, + ) + + assert_command_call( + mock_client, + device_url=DYNAMIC_EXTERIOR_VENETIAN_BLIND.device_url, + command_name="setClosureAndOrientation", + parameters=expected_parameters, + ) + + +async def test_set_cover_position_and_tilt_unsupported_command_raises( + hass: HomeAssistant, + setup_overkiz_integration: SetupOverkizIntegration, + mock_client: MockOverkizClient, +) -> None: + """ServiceValidationError must be raised when SET_CLOSURE_AND_ORIENTATION is missing. + + Defence-in-depth: even when a cover advertises both SET_POSITION and + SET_TILT_POSITION (so it passes the ``required_features`` filter), the + handler still checks the atomic command and aborts cleanly if it is + missing. + """ + await setup_overkiz_integration(fixture=DYNAMIC_EXTERIOR_VENETIAN_BLIND.fixture) + + with ( + patch( + "homeassistant.components.overkiz.executor.OverkizExecutor.has_command", + return_value=False, + ), + pytest.raises(ServiceValidationError), + ): + await hass.services.async_call( + "overkiz", + "set_cover_position_and_tilt", + { + ATTR_ENTITY_ID: DYNAMIC_EXTERIOR_VENETIAN_BLIND.entity_id, + ATTR_POSITION: 50, + ATTR_TILT_POSITION: 50, + }, + blocking=True, + ) + + assert mock_client.execute_command.await_count == 0 From 6f28902a4fbc886528bf00a660b3eecc17b2fa86 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Mon, 4 May 2026 14:24:33 -0400 Subject: [PATCH 10/22] Refactor hassio coordinators to use typed dataclasses instead of dicts (#168847) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../components/hassio/binary_sensor.py | 75 ++-- homeassistant/components/hassio/const.py | 10 +- .../components/hassio/coordinator.py | 334 ++++++++++++------ .../components/hassio/diagnostics.py | 6 +- homeassistant/components/hassio/entity.py | 128 +++---- homeassistant/components/hassio/sensor.py | 150 +++++--- homeassistant/components/hassio/switch.py | 20 +- homeassistant/components/hassio/update.py | 56 ++- tests/components/hassio/common.py | 1 + tests/components/hassio/test_init.py | 207 +++++++++++ tests/components/http/test_ban.py | 7 + 11 files changed, 679 insertions(+), 315 deletions(-) diff --git a/homeassistant/components/hassio/binary_sensor.py b/homeassistant/components/hassio/binary_sensor.py index 111e053f3fb5b..4c4819169b579 100644 --- a/homeassistant/components/hassio/binary_sensor.py +++ b/homeassistant/components/hassio/binary_sensor.py @@ -1,8 +1,9 @@ """Binary sensor platform for Hass.io addons.""" +from collections.abc import Callable from dataclasses import dataclass -import itertools +from aiohasupervisor.models import AddonState from aiohasupervisor.models.mounts import MountState from homeassistant.components.binary_sensor import ( @@ -14,41 +15,46 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ADDONS_COORDINATOR, - ATTR_STARTED, - ATTR_STATE, - DATA_KEY_ADDONS, - DATA_KEY_MOUNTS, - MAIN_COORDINATOR, -) +from .const import ADDONS_COORDINATOR, MAIN_COORDINATOR from .entity import HassioAddonEntity, HassioMountEntity -@dataclass(frozen=True) -class HassioBinarySensorEntityDescription(BinarySensorEntityDescription): - """Hassio binary sensor entity description.""" +@dataclass(frozen=True, kw_only=True) +class HassioAddonBinarySensorEntityDescription(BinarySensorEntityDescription): + """Hass.io add-on binary sensor entity description.""" + + value_fn: Callable[[HassioAddonBinarySensor], bool] + - target: str | None = None +@dataclass(frozen=True, kw_only=True) +class HassioMountBinarySensorEntityDescription(BinarySensorEntityDescription): + """Hass.io mount binary sensor entity description.""" + + value_fn: Callable[[HassioMountBinarySensor], bool] ADDON_ENTITY_DESCRIPTIONS = ( - HassioBinarySensorEntityDescription( + HassioAddonBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.RUNNING, entity_registry_enabled_default=False, - key=ATTR_STATE, + key="state", translation_key="state", - target=ATTR_STARTED, + value_fn=lambda entity: ( + entity.coordinator.data.addons[entity.addon_slug].addon.state + == AddonState.STARTED + ), ), ) MOUNT_ENTITY_DESCRIPTIONS = ( - HassioBinarySensorEntityDescription( + HassioMountBinarySensorEntityDescription( device_class=BinarySensorDeviceClass.CONNECTIVITY, entity_registry_enabled_default=False, - key=ATTR_STATE, + key="state", translation_key="mount", - target=MountState.ACTIVE.value, + value_fn=lambda entity: ( + entity.coordinator.data.mounts[entity.mount_name].state == MountState.ACTIVE + ), ), ) @@ -63,57 +69,46 @@ async def async_setup_entry( coordinator = hass.data[MAIN_COORDINATOR] async_add_entities( - itertools.chain( - [ + [ + *[ HassioAddonBinarySensor( addon=addon, coordinator=addons_coordinator, entity_description=entity_description, ) - for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() + for addon in addons_coordinator.data.addons.values() for entity_description in ADDON_ENTITY_DESCRIPTIONS ], - [ + *[ HassioMountBinarySensor( mount=mount, coordinator=coordinator, entity_description=entity_description, ) - for mount in coordinator.data[DATA_KEY_MOUNTS].values() + for mount in coordinator.data.mounts.values() for entity_description in MOUNT_ENTITY_DESCRIPTIONS ], - ) + ] ) class HassioAddonBinarySensor(HassioAddonEntity, BinarySensorEntity): """Binary sensor for Hass.io add-ons.""" - entity_description: HassioBinarySensorEntityDescription + entity_description: HassioAddonBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - value = self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ - self.entity_description.key - ] - if self.entity_description.target is None: - return value - return value == self.entity_description.target + return self.entity_description.value_fn(self) class HassioMountBinarySensor(HassioMountEntity, BinarySensorEntity): """Binary sensor for Hass.io mount.""" - entity_description: HassioBinarySensorEntityDescription + entity_description: HassioMountBinarySensorEntityDescription @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" - value = getattr( - self.coordinator.data[DATA_KEY_MOUNTS][self._mount.name], - self.entity_description.key, - ) - if self.entity_description.target is None: - return value - return value == self.entity_description.target + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 46033c7784e50..6978b545766eb 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -8,9 +8,11 @@ if TYPE_CHECKING: from aiohasupervisor.models import ( + AddonsStats, HomeAssistantInfo, HostInfo, InstalledAddon, + InstalledAddonComplete, NetworkInfo, OSInfo, RootInfo, @@ -112,8 +114,12 @@ DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info") DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info") DATA_SUPERVISOR_STATS = "hassio_supervisor_stats" -DATA_ADDONS_INFO = "hassio_addons_info" -DATA_ADDONS_STATS = "hassio_addons_stats" +DATA_ADDONS_INFO: HassKey[dict[str, InstalledAddonComplete | None]] = HassKey( + "hassio_addons_info" +) +DATA_ADDONS_STATS: HassKey[dict[str, AddonsStats | None]] = HassKey( + "hassio_addons_stats" +) DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list") HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5) HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15) diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index 1eee409a20fb6..5ca558fbc72d0 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -3,17 +3,20 @@ import asyncio from collections import defaultdict from collections.abc import Awaitable -from copy import deepcopy +from dataclasses import dataclass import logging from typing import TYPE_CHECKING, Any, cast from aiohasupervisor import SupervisorError, SupervisorNotFoundError from aiohasupervisor.models import ( + AddonsStats, AddonState, CIFSMountResponse, HomeAssistantInfo, + HomeAssistantStats, HostInfo, InstalledAddon, + InstalledAddonComplete, NetworkInfo, NFSMountResponse, OSInfo, @@ -21,10 +24,11 @@ RootInfo, StoreInfo, SupervisorInfo, + SupervisorStats, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME +from homeassistant.const import ATTR_MANUFACTURER from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.helpers import device_registry as dr from homeassistant.helpers.debounce import Debouncer @@ -34,15 +38,10 @@ from .const import ( ATTR_ADDONS, - ATTR_AUTO_UPDATE, ATTR_DATA, ATTR_REPOSITORIES, - ATTR_REPOSITORY, - ATTR_SLUG, ATTR_STARTUP, ATTR_UPDATE_KEY, - ATTR_URL, - ATTR_VERSION, ATTR_WS_EVENT, CONTAINER_STATS, CORE_CONTAINER, @@ -53,12 +52,6 @@ DATA_CORE_STATS, DATA_HOST_INFO, DATA_INFO, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_MOUNTS, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, DATA_KEY_SUPERVISOR_ISSUES, DATA_NETWORK_INFO, DATA_OS_INFO, @@ -86,6 +79,106 @@ _LOGGER = logging.getLogger(__name__) +@dataclass +class HassioMainData: + """Data class for HassioMainDataUpdateCoordinator.""" + + core: HomeAssistantInfo + supervisor: SupervisorInfo + host: HostInfo + mounts: dict[str, CIFSMountResponse | NFSMountResponse] + os: OSInfo | None + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "core": self.core.to_dict(), + "supervisor": self.supervisor.to_dict(), + "host": self.host.to_dict(), + "mounts": {name: mount.to_dict() for name, mount in self.mounts.items()}, + "os": self.os.to_dict() if self.os is not None else None, + } + + +@dataclass +class AddonData: + """Data for a single installed addon.""" + + addon: InstalledAddon + auto_update: bool + repository: str + + +@dataclass +class HassioAddonData: + """Data class for HassioAddOnDataUpdateCoordinator.""" + + addons: dict[str, AddonData] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "addons": { + slug: { + "addon": addon_data.addon.to_dict(), + "auto_update": addon_data.auto_update, + "repository": addon_data.repository, + } + for slug, addon_data in self.addons.items() + }, + } + + +@dataclass +class HassioStatsData: + """Data class for HassioStatsDataUpdateCoordinator.""" + + core: HomeAssistantStats | None + supervisor: SupervisorStats | None + addons: dict[str, AddonsStats | None] + + def to_dict(self) -> dict[str, Any]: + """Return a dictionary representation of the data.""" + return { + "core": self.core.to_dict() if self.core is not None else None, + "supervisor": ( + self.supervisor.to_dict() if self.supervisor is not None else None + ), + "addons": { + slug: stats.to_dict() if stats is not None else None + for slug, stats in self.addons.items() + }, + } + + +def _installed_addon_from_complete(info: InstalledAddonComplete) -> InstalledAddon: + """Build an InstalledAddon from an InstalledAddonComplete object. + + InstalledAddonComplete contains a superset of InstalledAddon fields. + This helper extracts only the fields needed for InstalledAddon so fresh + data from an addon_info call can be stored in AddonData.addon. + """ + return InstalledAddon( + advanced=info.advanced, + available=info.available, + build=info.build, + description=info.description, + homeassistant=info.homeassistant, + icon=info.icon, + logo=info.logo, + name=info.name, + repository=info.repository, + slug=info.slug, + stage=info.stage, + update_available=info.update_available, + url=info.url, + version_latest=info.version_latest, + version=info.version, + detached=info.detached, + state=info.state, + ) + + @callback def get_info(hass: HomeAssistant) -> dict[str, Any] | None: """Return generic information from Supervisor. @@ -151,7 +244,25 @@ def get_addons_info(hass: HomeAssistant) -> dict[str, dict[str, Any] | None] | N Async friendly. """ - return hass.data.get(DATA_ADDONS_INFO) + addons_info: dict[str, InstalledAddonComplete | None] | None = hass.data.get( + DATA_ADDONS_INFO + ) + if addons_info is None: + return None + # Converting these fields for compatibility as that is what was returned here. + # We'll leave it this way as long as these component APIs continue to return + # dictionaries. If/when we switch to using the aiohasupervisor models for everything + # internally and externally that will be dropped. + return { + slug: dict( + hassio_api=info.supervisor_api, + hassio_role=info.supervisor_role, + **info.to_dict(), + ) + if info is not None + else None + for slug, info in addons_info.items() + } @callback @@ -170,7 +281,11 @@ def get_addons_stats(hass: HomeAssistant) -> dict[str, dict[str, Any] | None]: Async friendly. """ - return hass.data.get(DATA_ADDONS_STATS) or {} + addons_stats: dict[str, AddonsStats | None] = hass.data.get(DATA_ADDONS_STATS) or {} + return { + slug: stats.to_dict() if stats is not None else None + for slug, stats in addons_stats.items() + } @callback @@ -179,7 +294,8 @@ def get_core_stats(hass: HomeAssistant) -> dict[str, Any]: Async friendly. """ - return hass.data.get(DATA_CORE_STATS) or {} + stats = hass.data.get(DATA_CORE_STATS) + return stats.to_dict() if stats is not None else {} @callback @@ -188,7 +304,8 @@ def get_supervisor_stats(hass: HomeAssistant) -> dict[str, Any]: Async friendly. """ - return hass.data.get(DATA_SUPERVISOR_STATS) or {} + stats = hass.data.get(DATA_SUPERVISOR_STATS) + return stats.to_dict() if stats is not None else {} @callback @@ -222,19 +339,20 @@ def get_issues_info(hass: HomeAssistant) -> SupervisorIssues | None: @callback def async_register_addons_in_dev_reg( - entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[dict[str, Any]] + entry_id: str, dev_reg: dr.DeviceRegistry, addons: list[AddonData] ) -> None: """Register addons in the device registry.""" - for addon in addons: + for addon_data in addons: + addon = addon_data.addon params = DeviceInfo( - identifiers={(DOMAIN, addon[ATTR_SLUG])}, + identifiers={(DOMAIN, addon.slug)}, model=SupervisorEntityModel.ADDON, - sw_version=addon[ATTR_VERSION], - name=addon[ATTR_NAME], + sw_version=addon.version, + name=addon.name, entry_type=dr.DeviceEntryType.SERVICE, - configuration_url=f"homeassistant://hassio/addon/{addon[ATTR_SLUG]}", + configuration_url=f"homeassistant://hassio/addon/{addon.slug}", ) - if manufacturer := addon.get(ATTR_REPOSITORY) or addon.get(ATTR_URL): + if manufacturer := addon_data.repository or addon.url: params[ATTR_MANUFACTURER] = manufacturer dev_reg.async_get_or_create(config_entry_id=entry_id, **params) @@ -260,14 +378,14 @@ def async_register_mounts_in_dev_reg( @callback def async_register_os_in_dev_reg( - entry_id: str, dev_reg: dr.DeviceRegistry, os_dict: dict[str, Any] + entry_id: str, dev_reg: dr.DeviceRegistry, os_info: OSInfo ) -> None: """Register OS in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "OS")}, manufacturer="Home Assistant", model=SupervisorEntityModel.OS, - sw_version=os_dict[ATTR_VERSION], + sw_version=os_info.version, name="Home Assistant Operating System", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -294,14 +412,14 @@ def async_register_host_in_dev_reg( def async_register_core_in_dev_reg( entry_id: str, dev_reg: dr.DeviceRegistry, - core_dict: dict[str, Any], + core_info: HomeAssistantInfo, ) -> None: - """Register OS in the device registry.""" + """Register core in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "core")}, manufacturer="Home Assistant", model=SupervisorEntityModel.CORE, - sw_version=core_dict[ATTR_VERSION], + sw_version=core_info.version, name="Home Assistant Core", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -312,14 +430,14 @@ def async_register_core_in_dev_reg( def async_register_supervisor_in_dev_reg( entry_id: str, dev_reg: dr.DeviceRegistry, - supervisor_dict: dict[str, Any], + supervisor_info: SupervisorInfo, ) -> None: - """Register OS in the device registry.""" + """Register supervisor in the device registry.""" params = DeviceInfo( identifiers={(DOMAIN, "supervisor")}, manufacturer="Home Assistant", model=SupervisorEntityModel.SUPERVISOR, - sw_version=supervisor_dict[ATTR_VERSION], + sw_version=supervisor_info.version, name="Home Assistant Supervisor", entry_type=dr.DeviceEntryType.SERVICE, ) @@ -336,7 +454,7 @@ def async_remove_devices_from_dev_reg( dev_reg.async_remove_device(dev.id) -class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[HassioStatsData]): """Class to retrieve Hass.io container stats.""" config_entry: ConfigEntry @@ -358,18 +476,18 @@ def __init__(self, hass: HomeAssistant, config_entry: ConfigEntry) -> None: lambda: defaultdict(set) ) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> HassioStatsData: """Update stats data via library.""" try: await self._fetch_stats() except SupervisorError as err: raise UpdateFailed(f"Error on Supervisor API: {err}") from err - new_data: dict[str, Any] = {} - new_data[DATA_KEY_CORE] = get_core_stats(self.hass) - new_data[DATA_KEY_SUPERVISOR] = get_supervisor_stats(self.hass) - new_data[DATA_KEY_ADDONS] = get_addons_stats(self.hass) - return new_data + return HassioStatsData( + core=self.hass.data.get(DATA_CORE_STATS), + supervisor=self.hass.data.get(DATA_SUPERVISOR_STATS), + addons=self.hass.data.get(DATA_ADDONS_STATS) or {}, + ) async def _fetch_stats(self) -> None: """Fetch container stats for subscribed entities.""" @@ -387,7 +505,7 @@ async def _fetch_stats(self) -> None: if updates: api_results: list[ResponseData] = await asyncio.gather(*updates.values()) for key, result in zip(updates, api_results, strict=True): - data[key] = result.to_dict() + data[key] = result # Fetch addon stats addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or [] @@ -397,7 +515,9 @@ async def _fetch_stats(self) -> None: if addon.state in {AddonState.STARTED, AddonState.STARTUP} } - addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {}) + addons_stats: dict[str, AddonsStats | None] = data.setdefault( + DATA_ADDONS_STATS, {} + ) # Clean up cache for stopped/removed addons for slug in addons_stats.keys() - started_addons: @@ -415,14 +535,14 @@ async def _fetch_stats(self) -> None: ) addons_stats.update(addon_stats_results) - async def _update_addon_stats(self, slug: str) -> tuple[str, dict[str, Any] | None]: + async def _update_addon_stats(self, slug: str) -> tuple[str, AddonsStats | None]: """Update single addon stats.""" try: stats = await self.supervisor_client.addons.addon_stats(slug) except SupervisorError as err: _LOGGER.warning("Could not fetch stats for %s: %s", slug, err) return (slug, None) - return (slug, stats.to_dict()) + return (slug, stats) @callback def async_enable_container_updates( @@ -445,7 +565,7 @@ def _remove() -> None: return _remove -class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[HassioAddonData]): """Class to retrieve Hass.io Add-on status.""" config_entry: ConfigEntry @@ -476,7 +596,7 @@ def __init__( self.supervisor_client = get_supervisor_client(hass) self.jobs = jobs - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> HassioAddonData: """Update data via library.""" is_first_update = not self.data client = self.supervisor_client @@ -487,7 +607,7 @@ async def _async_update_data(self) -> dict[str, Any]: # Fetch addon info for all addons on first update, or only # for addons with subscribed entities on subsequent updates. - addon_info_results = dict( + addon_info_results: dict[str, InstalledAddonComplete | None] = dict( await asyncio.gather( *[ self._update_addon_info(slug) @@ -503,39 +623,35 @@ async def _async_update_data(self) -> dict[str, Any]: self.hass.data[DATA_ADDONS_LIST] = installed_addons # Update addon info cache in hass.data - addon_info_cache: dict[str, Any] = self.hass.data.setdefault( - DATA_ADDONS_INFO, {} - ) + addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) for slug in addon_info_cache.keys() - all_addons: del addon_info_cache[slug] addon_info_cache.update(addon_info_results) - # Build clean coordinator data + # Build repository name lookup from store data store = self.hass.data.get(DATA_STORE) - if store: - repositories = {repo.slug: repo.name for repo in store.repositories} - else: - repositories = {} - - addons_list_dicts = [addon.to_dict() for addon in installed_addons] - new_data: dict[str, Any] = {} - new_data[DATA_KEY_ADDONS] = { - (slug := addon[ATTR_SLUG]): { - **addon, - ATTR_AUTO_UPDATE: (addon_info_cache.get(slug) or {}).get( - ATTR_AUTO_UPDATE, False - ), - ATTR_REPOSITORY: repositories.get( - repo_slug := addon.get(ATTR_REPOSITORY, ""), repo_slug - ), - } - for addon in addons_list_dicts - } + repositories: dict[str, str] = ( + {repo.slug: repo.name for repo in store.repositories} if store else {} + ) + + # Build clean coordinator data + new_addons: dict[str, AddonData] = {} + for addon in installed_addons: + addon_info = addon_info_cache.get(addon.slug) + auto_update = addon_info.auto_update if addon_info is not None else False + repo_slug = addon.repository + repository = repositories.get(repo_slug, repo_slug) + new_addons[addon.slug] = AddonData( + addon=addon, + auto_update=auto_update, + repository=repository, + ) + new_data = HassioAddonData(addons=new_addons) # If this is the initial refresh, register all addons if is_first_update: async_register_addons_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_ADDONS].values() + self.entry_id, self.dev_reg, list(new_data.addons.values()) ) # Remove add-ons that are no longer installed from device registry @@ -546,19 +662,16 @@ async def _async_update_data(self) -> dict[str, Any]: ) if device.model == SupervisorEntityModel.ADDON } - if stale_addons := supervisor_addon_devices - set(new_data[DATA_KEY_ADDONS]): + if stale_addons := supervisor_addon_devices - set(new_data.addons): async_remove_devices_from_dev_reg(self.dev_reg, stale_addons) # If there are new add-ons, we should reload the config entry so we can - # create new devices and entities. We can return an empty dict because + # create new devices and entities. We can return the new data because # coordinator will be recreated. - if self.data and ( - set(new_data[DATA_KEY_ADDONS]) - set(self.data[DATA_KEY_ADDONS]) - ): + if self.data and (set(new_data.addons) - set(self.data.addons)): self.hass.async_create_task( self.hass.config_entries.async_reload(self.entry_id) ) - return {} return new_data @@ -569,18 +682,16 @@ async def get_changelog(self, addon_slug: str) -> str | None: except SupervisorNotFoundError: return None - async def _update_addon_info(self, slug: str) -> tuple[str, dict[str, Any] | None]: + async def _update_addon_info( + self, slug: str + ) -> tuple[str, InstalledAddonComplete | None]: """Return the info for an addon.""" try: info = await self.supervisor_client.addons.addon_info(slug) except SupervisorError as err: _LOGGER.warning("Could not fetch info for %s: %s", slug, err) return (slug, None) - # Translate to legacy hassio names for compatibility - info_dict = info.to_dict() - info_dict["hassio_api"] = info_dict.pop("supervisor_api") - info_dict["hassio_role"] = info_dict.pop("supervisor_role") - return (slug, info_dict) + return (slug, info) @callback def async_enable_addon_info_updates( @@ -627,16 +738,26 @@ async def force_addon_info_data_refresh(self, addon_slug: str) -> None: """Force refresh of addon info data for a specific addon.""" try: slug, info = await self._update_addon_info(addon_slug) - if info is not None and DATA_KEY_ADDONS in self.data: - if slug in self.data[DATA_KEY_ADDONS]: - data = deepcopy(self.data) - data[DATA_KEY_ADDONS][slug].update(info) - self.async_set_updated_data(data) except SupervisorError as err: _LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err) + return + + if info is not None and self.data and slug in self.data.addons: + updated = AddonData( + addon=_installed_addon_from_complete(info), + auto_update=info.auto_update, + repository=self.data.addons[slug].repository, + ) + self.async_set_updated_data( + HassioAddonData(addons={**self.data.addons, slug: updated}) + ) + + # Update addon info cache in hass.data + addon_info_cache = self.hass.data.setdefault(DATA_ADDONS_INFO, {}) + addon_info_cache[slug] = info -class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]): +class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[HassioMainData]): """Class to retrieve Hass.io status.""" config_entry: ConfigEntry @@ -679,7 +800,7 @@ def _supervisor_event(self, event: dict[str, Any]) -> None: ): self.config_entry.async_create_task(self.hass, self.async_request_refresh()) - async def _async_update_data(self) -> dict[str, Any]: + async def _async_update_data(self) -> HassioMainData: """Update data via library.""" is_first_update = not self.data client = self.supervisor_client @@ -722,13 +843,13 @@ async def _async_update_data(self) -> dict[str, Any]: raise UpdateFailed(f"Error on Supervisor API: {err}") from err # Build clean coordinator data - new_data: dict[str, Any] = {} - new_data[DATA_KEY_CORE] = core_info.to_dict() - new_data[DATA_KEY_SUPERVISOR] = supervisor_info.to_dict() - new_data[DATA_KEY_HOST] = host_info.to_dict() - new_data[DATA_KEY_MOUNTS] = {mount.name: mount for mount in mounts_info.mounts} - if self.is_hass_os: - new_data[DATA_KEY_OS] = os_info.to_dict() + new_data = HassioMainData( + core=core_info, + supervisor=supervisor_info, + host=host_info, + mounts={mount.name: mount for mount in mounts_info.mounts}, + os=os_info if self.is_hass_os else None, + ) # Update hass.data for legacy accessor functions self.hass.data[DATA_INFO] = info @@ -742,19 +863,15 @@ async def _async_update_data(self) -> dict[str, Any]: # If this is the initial refresh, register all main components if is_first_update: async_register_mounts_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_MOUNTS].values() - ) - async_register_core_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_CORE] + self.entry_id, self.dev_reg, list(new_data.mounts.values()) ) + async_register_core_in_dev_reg(self.entry_id, self.dev_reg, new_data.core) async_register_supervisor_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_SUPERVISOR] + self.entry_id, self.dev_reg, new_data.supervisor ) async_register_host_in_dev_reg(self.entry_id, self.dev_reg) if self.is_hass_os: - async_register_os_in_dev_reg( - self.entry_id, self.dev_reg, new_data[DATA_KEY_OS] - ) + async_register_os_in_dev_reg(self.entry_id, self.dev_reg, os_info) # Remove mounts that no longer exists from device registry supervisor_mount_devices = { @@ -764,7 +881,7 @@ async def _async_update_data(self) -> dict[str, Any]: ) if device.model == SupervisorEntityModel.MOUNT } - if stale_mounts := supervisor_mount_devices - set(new_data[DATA_KEY_MOUNTS]): + if stale_mounts := supervisor_mount_devices - set(new_data.mounts): async_remove_devices_from_dev_reg( self.dev_reg, {f"mount_{stale_mount}" for stale_mount in stale_mounts} ) @@ -776,15 +893,12 @@ async def _async_update_data(self) -> dict[str, Any]: self.dev_reg.async_remove_device(dev.id) # If there are new mounts, we should reload the config entry so we can - # create new devices and entities. We can return an empty dict because + # create new devices and entities. We can return the new data because # coordinator will be recreated. - if self.data and ( - set(new_data[DATA_KEY_MOUNTS]) - set(self.data.get(DATA_KEY_MOUNTS, {})) - ): + if self.data and (set(new_data.mounts) - set(self.data.mounts)): self.hass.async_create_task( self.hass.config_entries.async_reload(self.entry_id) ) - return {} return new_data diff --git a/homeassistant/components/hassio/diagnostics.py b/homeassistant/components/hassio/diagnostics.py index 704f6770b7709..a3166d15888d4 100644 --- a/homeassistant/components/hassio/diagnostics.py +++ b/homeassistant/components/hassio/diagnostics.py @@ -56,8 +56,8 @@ async def async_get_config_entry_diagnostics( devices.append({"device": asdict(device), "entities": entities}) return { - "coordinator_data": coordinator.data, - "addons_coordinator_data": addons_coordinator.data, - "stats_coordinator_data": stats_coordinator.data, + "coordinator_data": coordinator.data.to_dict(), + "addons_coordinator_data": addons_coordinator.data.to_dict(), + "stats_coordinator_data": stats_coordinator.data.to_dict(), "devices": devices, } diff --git a/homeassistant/components/hassio/entity.py b/homeassistant/components/hassio/entity.py index b3551cd494c77..616862ed65e8c 100644 --- a/homeassistant/components/hassio/entity.py +++ b/homeassistant/components/hassio/entity.py @@ -1,27 +1,20 @@ """Base for Hass.io entities.""" -from typing import Any +from collections.abc import Callable -from aiohasupervisor.models.mounts import CIFSMountResponse, NFSMountResponse +from aiohasupervisor.models import CIFSMountResponse, HostInfo, NFSMountResponse, OSInfo +from aiohasupervisor.models.base import ContainerStats from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.update_coordinator import CoordinatorEntity -from .const import ( - ATTR_SLUG, - CONTAINER_STATS, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_MOUNTS, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, - DOMAIN, -) +from .const import CONTAINER_STATS, DOMAIN from .coordinator import ( + AddonData, HassioAddOnDataUpdateCoordinator, HassioMainDataUpdateCoordinator, + HassioStatsData, HassioStatsDataUpdateCoordinator, ) @@ -37,7 +30,7 @@ def __init__( entity_description: EntityDescription, *, container_id: str, - data_key: str, + stats_fn: Callable[[HassioStatsData], ContainerStats | None], device_id: str, unique_id_prefix: str, ) -> None: @@ -45,27 +38,25 @@ def __init__( super().__init__(coordinator) self.entity_description = entity_description self._container_id = container_id - self._data_key = data_key + self._stats_fn = stats_fn self._attr_unique_id = f"{unique_id_prefix}_{entity_description.key}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, device_id)}) + @property + def _stats(self) -> ContainerStats | None: + """Return the stats object for this entity's container.""" + return self._stats_fn(self.coordinator.data) + + @property + def stats(self) -> ContainerStats: + """Return the stats object, asserting it is available.""" + assert self._stats is not None + return self._stats + @property def available(self) -> bool: """Return True if entity is available.""" - if self._data_key == DATA_KEY_ADDONS: - return ( - super().available - and DATA_KEY_ADDONS in self.coordinator.data - and self.entity_description.key - in ( - self.coordinator.data[DATA_KEY_ADDONS].get(self._container_id) or {} - ) - ) - return ( - super().available - and self._data_key in self.coordinator.data - and self.entity_description.key in self.coordinator.data[self._data_key] - ) + return super().available and self._stats is not None async def async_added_to_hass(self) -> None: """Subscribe to stats updates.""" @@ -92,24 +83,31 @@ def __init__( self, coordinator: HassioAddOnDataUpdateCoordinator, entity_description: EntityDescription, - addon: dict[str, Any], + addon: AddonData, ) -> None: """Initialize base entity.""" super().__init__(coordinator) self.entity_description = entity_description - self._addon_slug = addon[ATTR_SLUG] - self._attr_unique_id = f"{addon[ATTR_SLUG]}_{entity_description.key}" - self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon[ATTR_SLUG])}) + self._addon_slug = addon.addon.slug + self._attr_unique_id = f"{addon.addon.slug}_{entity_description.key}" + self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, addon.addon.slug)}) + + @property + def addon_slug(self) -> str: + """Return the add-on slug.""" + return self._addon_slug + + @property + def addon_data(self) -> AddonData: + """Return the add-on data, asserting it is available.""" + data = self.coordinator.data + assert self._addon_slug in data.addons + return data.addons[self._addon_slug] @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_ADDONS in self.coordinator.data - and self.entity_description.key - in self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - ) + return super().available and self._addon_slug in self.coordinator.data.addons async def async_added_to_hass(self) -> None: """Subscribe to addon info updates.""" @@ -140,11 +138,13 @@ def __init__( @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_OS in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_OS] - ) + return super().available and self.coordinator.data.os is not None + + @property + def os(self) -> OSInfo: + """Return the OS info object, asserting it is available.""" + assert self.coordinator.data.os is not None + return self.coordinator.data.os class HassioHostEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): @@ -164,13 +164,10 @@ def __init__( self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "host")}) @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_HOST in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_HOST] - ) + def host(self) -> HostInfo: + """Return the host info, asserting it is available.""" + assert self.coordinator.data.host is not None + return self.coordinator.data.host class HassioSupervisorEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): @@ -189,16 +186,6 @@ def __init__( self._attr_unique_id = f"home_assistant_supervisor_{entity_description.key}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "supervisor")}) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_SUPERVISOR in self.coordinator.data - and self.entity_description.key - in self.coordinator.data[DATA_KEY_SUPERVISOR] - ) - class HassioCoreEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Core.""" @@ -216,15 +203,6 @@ def __init__( self._attr_unique_id = f"home_assistant_core_{entity_description.key}" self._attr_device_info = DeviceInfo(identifiers={(DOMAIN, "core")}) - @property - def available(self) -> bool: - """Return True if entity is available.""" - return ( - super().available - and DATA_KEY_CORE in self.coordinator.data - and self.entity_description.key in self.coordinator.data[DATA_KEY_CORE] - ) - class HassioMountEntity(CoordinatorEntity[HassioMainDataUpdateCoordinator]): """Base Entity for Mount.""" @@ -248,10 +226,12 @@ def __init__( ) self._mount = mount + @property + def mount_name(self) -> str: + """Return the mount name.""" + return self._mount.name + @property def available(self) -> bool: """Return True if entity is available.""" - return ( - super().available - and self._mount.name in self.coordinator.data[DATA_KEY_MOUNTS] - ) + return super().available and self.mount_name in self.coordinator.data.mounts diff --git a/homeassistant/components/hassio/sensor.py b/homeassistant/components/hassio/sensor.py index 6b5014fc76ef0..8acc48803889e 100644 --- a/homeassistant/components/hassio/sensor.py +++ b/homeassistant/components/hassio/sensor.py @@ -1,5 +1,10 @@ """Sensor platform for Hass.io addons.""" +from collections.abc import Callable +from dataclasses import dataclass + +from aiohasupervisor.models.base import ContainerStats + from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, @@ -15,19 +20,12 @@ ADDONS_COORDINATOR, ATTR_CPU_PERCENT, ATTR_MEMORY_PERCENT, - ATTR_SLUG, - ATTR_VERSION, - ATTR_VERSION_LATEST, CORE_CONTAINER, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_HOST, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, MAIN_COORDINATOR, STATS_COORDINATOR, SUPERVISOR_CONTAINER, ) +from .coordinator import HassioStatsData from .entity import ( HassioAddonEntity, HassioHostEntity, @@ -35,74 +33,125 @@ HassioStatsEntity, ) -COMMON_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + +@dataclass(frozen=True, kw_only=True) +class HassioAddonSensorEntityDescription(SensorEntityDescription): + """Hass.io add-on sensor entity description.""" + + value_fn: Callable[[HassioAddonSensor], str | None] + + +@dataclass(frozen=True, kw_only=True) +class HassioStatsSensorEntityDescription(SensorEntityDescription): + """Hass.io stats sensor entity description.""" + + value_fn: Callable[[HassioStatsSensor], float] + + +@dataclass(frozen=True, kw_only=True) +class HassioOSSensorEntityDescription(SensorEntityDescription): + """Hass.io OS sensor entity description.""" + + value_fn: Callable[[HassioOSSensor], str | None] + + +@dataclass(frozen=True, kw_only=True) +class HassioHostSensorEntityDescription(SensorEntityDescription): + """Hass.io host sensor entity description.""" + + value_fn: Callable[[HostSensor], str | float | None] + + +ADDON_ENTITY_DESCRIPTIONS = ( + HassioAddonSensorEntityDescription( entity_registry_enabled_default=False, - key=ATTR_VERSION, + key="version", translation_key="version", + value_fn=lambda entity: entity.addon_data.addon.version, ), - SensorEntityDescription( + HassioAddonSensorEntityDescription( entity_registry_enabled_default=False, - key=ATTR_VERSION_LATEST, + key="version_latest", translation_key="version_latest", + value_fn=lambda entity: entity.addon_data.addon.version_latest, ), ) STATS_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + HassioStatsSensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_CPU_PERCENT, translation_key="cpu_percent", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.stats.cpu_percent, ), - SensorEntityDescription( + HassioStatsSensorEntityDescription( entity_registry_enabled_default=False, key=ATTR_MEMORY_PERCENT, translation_key="memory_percent", native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda entity: entity.stats.memory_percent, ), ) -OS_ENTITY_DESCRIPTIONS = COMMON_ENTITY_DESCRIPTIONS +OS_ENTITY_DESCRIPTIONS = ( + HassioOSSensorEntityDescription( + entity_registry_enabled_default=False, + key="version", + translation_key="version", + value_fn=lambda entity: entity.os.version, + ), + HassioOSSensorEntityDescription( + entity_registry_enabled_default=False, + key="version_latest", + translation_key="version_latest", + value_fn=lambda entity: entity.os.version_latest, + ), +) HOST_ENTITY_DESCRIPTIONS = ( - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="agent_version", translation_key="agent_version", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.agent_version, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="apparmor_version", translation_key="apparmor_version", entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.apparmor_version, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_total", translation_key="disk_total", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_total, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_used", translation_key="disk_used", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_used, ), - SensorEntityDescription( + HassioHostSensorEntityDescription( entity_registry_enabled_default=False, key="disk_free", translation_key="disk_free", native_unit_of_measurement=UnitOfInformation.GIGABYTES, device_class=SensorDeviceClass.DATA_SIZE, entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda entity: entity.host.disk_free, ), ) @@ -126,21 +175,32 @@ async def async_setup_entry( coordinator=addons_coordinator, entity_description=entity_description, ) - for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() - for entity_description in COMMON_ENTITY_DESCRIPTIONS + for addon in addons_coordinator.data.addons.values() + for entity_description in ADDON_ENTITY_DESCRIPTIONS ) # Add-on stats sensors (cpu_percent, memory_percent) + def stats_fn_factory( + addon_slug: str, + ) -> Callable[[HassioStatsData], ContainerStats | None]: + """Return a stats_fn for the given add-on slug.""" + + def stats_fn(data: HassioStatsData) -> ContainerStats | None: + """Return the stats for the given add-on.""" + return data.addons.get(addon_slug) + + return stats_fn + entities.extend( HassioStatsSensor( coordinator=stats_coordinator, entity_description=entity_description, - container_id=addon[ATTR_SLUG], - data_key=DATA_KEY_ADDONS, - device_id=addon[ATTR_SLUG], - unique_id_prefix=addon[ATTR_SLUG], + container_id=addon.addon.slug, + stats_fn=stats_fn_factory(addon.addon.slug), + device_id=addon.addon.slug, + unique_id_prefix=addon.addon.slug, ) - for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() + for addon in addons_coordinator.data.addons.values() for entity_description in STATS_ENTITY_DESCRIPTIONS ) @@ -150,7 +210,7 @@ async def async_setup_entry( coordinator=stats_coordinator, entity_description=entity_description, container_id=CORE_CONTAINER, - data_key=DATA_KEY_CORE, + stats_fn=lambda data: data.core, device_id="core", unique_id_prefix="home_assistant_core", ) @@ -163,7 +223,7 @@ async def async_setup_entry( coordinator=stats_coordinator, entity_description=entity_description, container_id=SUPERVISOR_CONTAINER, - data_key=DATA_KEY_SUPERVISOR, + stats_fn=lambda data: data.supervisor, device_id="supervisor", unique_id_prefix="home_assistant_supervisor", ) @@ -195,40 +255,42 @@ async def async_setup_entry( class HassioAddonSensor(HassioAddonEntity, SensorEntity): """Sensor to track a Hass.io add-on attribute.""" + entity_description: HassioAddonSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug][ - self.entity_description.key - ] + return self.entity_description.value_fn(self) class HassioStatsSensor(HassioStatsEntity, SensorEntity): """Sensor to track container stats.""" + entity_description: HassioStatsSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> float: """Return native value of entity.""" - if self._data_key == DATA_KEY_ADDONS: - return self.coordinator.data[DATA_KEY_ADDONS][self._container_id][ - self.entity_description.key - ] - return self.coordinator.data[self._data_key][self.entity_description.key] + return self.entity_description.value_fn(self) class HassioOSSensor(HassioOSEntity, SensorEntity): """Sensor to track a Hass.io OS attribute.""" + entity_description: HassioOSSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> str | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_OS][self.entity_description.key] + return self.entity_description.value_fn(self) class HostSensor(HassioHostEntity, SensorEntity): """Sensor to track a host attribute.""" + entity_description: HassioHostSensorEntityDescription + @property - def native_value(self) -> str: + def native_value(self) -> str | float | None: """Return native value of entity.""" - return self.coordinator.data[DATA_KEY_HOST][self.entity_description.key] + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/hassio/switch.py b/homeassistant/components/hassio/switch.py index 9bf9374a53297..9454b917e33e6 100644 --- a/homeassistant/components/hassio/switch.py +++ b/homeassistant/components/hassio/switch.py @@ -4,15 +4,15 @@ from typing import Any from aiohasupervisor import SupervisorError +from aiohasupervisor.models import AddonState from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ADDONS_COORDINATOR, ATTR_STARTED, ATTR_STATE, DATA_KEY_ADDONS +from .const import ADDONS_COORDINATOR from .entity import HassioAddonEntity from .handler import get_supervisor_client @@ -20,7 +20,7 @@ ENTITY_DESCRIPTION = SwitchEntityDescription( - key=ATTR_STATE, + key="state", name=None, icon="mdi:puzzle", entity_registry_enabled_default=False, @@ -41,7 +41,7 @@ async def async_setup_entry( coordinator=coordinator, entity_description=ENTITY_DESCRIPTION, ) - for addon in coordinator.data[DATA_KEY_ADDONS].values() + for addon in coordinator.data.addons.values() ) @@ -49,19 +49,19 @@ class HassioAddonSwitch(HassioAddonEntity, SwitchEntity): """Switch for Hass.io add-ons.""" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the add-on is on.""" - addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - state = addon_data.get(self.entity_description.key) - return state == ATTR_STARTED + return ( + self.coordinator.data.addons[self._addon_slug].addon.state + == AddonState.STARTED + ) @property def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" if not self.available: return None - addon_data = self.coordinator.data[DATA_KEY_ADDONS].get(self._addon_slug, {}) - if addon_data.get(ATTR_ICON): + if self.coordinator.data.addons[self._addon_slug].addon.icon: return f"/api/hassio/addons/{self._addon_slug}/icon" return None diff --git a/homeassistant/components/hassio/update.py b/homeassistant/components/hassio/update.py index 44d36db7439e7..7005f1ac3245e 100644 --- a/homeassistant/components/hassio/update.py +++ b/homeassistant/components/hassio/update.py @@ -13,22 +13,12 @@ UpdateEntityFeature, ) from homeassistant.config_entries import ConfigEntry -from homeassistant.const import ATTR_ICON, ATTR_NAME from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import ( - ADDONS_COORDINATOR, - ATTR_AUTO_UPDATE, - ATTR_VERSION, - ATTR_VERSION_LATEST, - DATA_KEY_ADDONS, - DATA_KEY_CORE, - DATA_KEY_OS, - DATA_KEY_SUPERVISOR, - MAIN_COORDINATOR, -) +from .const import ADDONS_COORDINATOR, ATTR_VERSION_LATEST, MAIN_COORDINATOR +from .coordinator import AddonData from .entity import ( HassioAddonEntity, HassioCoreEntity, @@ -78,7 +68,7 @@ async def async_setup_entry( coordinator=addons_coordinator, entity_description=ENTITY_DESCRIPTION, ) - for addon in addons_coordinator.data[DATA_KEY_ADDONS].values() + for addon in addons_coordinator.data.addons.values() ) async_add_entities(entities) @@ -108,29 +98,29 @@ class SupervisorAddonUpdateEntity(HassioAddonEntity, UpdateEntity): _version_before_update: str | None = None @property - def _addon_data(self) -> dict: + def _addon_data(self) -> AddonData: """Return the add-on data.""" - return self.coordinator.data[DATA_KEY_ADDONS][self._addon_slug] + return self.coordinator.data.addons[self._addon_slug] @property def auto_update(self) -> bool: """Return true if auto-update is enabled for the add-on.""" - return self._addon_data[ATTR_AUTO_UPDATE] + return self._addon_data.auto_update @property def title(self) -> str | None: """Return the title of the update.""" - return self._addon_data[ATTR_NAME] + return self._addon_data.addon.name @property def latest_version(self) -> str | None: """Latest version available for install.""" - return self._addon_data[ATTR_VERSION_LATEST] + return self._addon_data.addon.version_latest @property def installed_version(self) -> str | None: """Version installed and in use.""" - return self._addon_data[ATTR_VERSION] + return self._addon_data.addon.version @property def in_progress(self) -> bool | None: @@ -144,7 +134,7 @@ def entity_picture(self) -> str | None: """Return the icon of the add-on if any.""" if not self.available: return None - if self._addon_data[ATTR_ICON]: + if self._addon_data.addon.icon: return f"/api/hassio/addons/{self._addon_slug}/icon" return None @@ -236,14 +226,16 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity): _attr_title = "Home Assistant Operating System" @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION_LATEST] + assert self.coordinator.data.os is not None + return self.coordinator.data.os.version_latest @property - def installed_version(self) -> str: + def installed_version(self) -> str | None: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_OS][ATTR_VERSION] + assert self.coordinator.data.os is not None + return self.coordinator.data.os.version @property def entity_picture(self) -> str | None: @@ -293,19 +285,19 @@ def in_progress(self) -> bool | None: return self._attr_in_progress @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION_LATEST] + return self.coordinator.data.supervisor.version_latest @property def installed_version(self) -> str: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_VERSION] + return self.coordinator.data.supervisor.version @property def auto_update(self) -> bool: """Return true if auto-update is enabled for supervisor.""" - return self.coordinator.data[DATA_KEY_SUPERVISOR][ATTR_AUTO_UPDATE] + return self.coordinator.data.supervisor.auto_update @property def release_url(self) -> str | None: @@ -389,14 +381,14 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity): _attr_title = "Home Assistant Core" @property - def latest_version(self) -> str: + def latest_version(self) -> str | None: """Return the latest version.""" - return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION_LATEST] + return self.coordinator.data.core.version_latest @property - def installed_version(self) -> str: + def installed_version(self) -> str | None: """Return the installed version.""" - return self.coordinator.data[DATA_KEY_CORE][ATTR_VERSION] + return self.coordinator.data.core.version @property def entity_picture(self) -> str | None: diff --git a/tests/components/hassio/common.py b/tests/components/hassio/common.py index d98d68e48aa40..45f9d11e3e2fd 100644 --- a/tests/components/hassio/common.py +++ b/tests/components/hassio/common.py @@ -120,6 +120,7 @@ def mock_addon_info( supervisor_api=False, supervisor_role="default", icon=False, + auto_update=False, ) addon_info.name = "test" addon_info.to_dict = MethodType( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 5978de0c92b38..9f633112a2999 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -37,8 +37,18 @@ from homeassistant.components.hassio import ( ADDONS_COORDINATOR, DOMAIN, + get_addons_info, + get_addons_list, + get_addons_stats, get_core_info, + get_core_stats, + get_host_info, + get_info, + get_network_info, + get_os_info, + get_store, get_supervisor_info, + get_supervisor_stats, hostname_from_addon_slug, ) from homeassistant.components.hassio.config import STORAGE_KEY @@ -1522,3 +1532,200 @@ async def test_get_supervisor_info(hass: HomeAssistant) -> None: assert "addons" in result assert isinstance(result["addons"], list) assert all(isinstance(addon, dict) for addon in result["addons"]) + + +@pytest.mark.usefixtures("mock_all") +async def test_get_info(hass: HomeAssistant) -> None: + """Test get_info returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_info(hass) + assert isinstance(result, dict) + assert result["supervisor"] == "222" + assert result["homeassistant"] == "0.110.0" + assert result["hassos"] == "1.2.3" + + +@pytest.mark.usefixtures("mock_all") +async def test_get_host_info(hass: HomeAssistant) -> None: + """Test get_host_info returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_host_info(hass) + assert isinstance(result, dict) + assert result["chassis"] == "vm" + assert result["disk_total"] == 100.0 + assert result["kernel"] == "4.19.0-6-amd64" + + +@pytest.mark.usefixtures("mock_all") +async def test_get_store(hass: HomeAssistant) -> None: + """Test get_store returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_store(hass) + assert isinstance(result, dict) + assert "addons" in result + assert "repositories" in result + assert isinstance(result["addons"], list) + assert isinstance(result["repositories"], list) + + +@pytest.mark.usefixtures("mock_all") +async def test_get_network_info(hass: HomeAssistant) -> None: + """Test get_network_info returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_network_info(hass) + assert isinstance(result, dict) + assert result["host_internet"] is True + assert result["supervisor_internet"] is True + assert isinstance(result["interfaces"], list) + + +@pytest.mark.usefixtures("mock_all") +async def test_get_addons_info(hass: HomeAssistant) -> None: + """Test get_addons_info returns serialized dicts, not model objects.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_addons_info(hass) + assert isinstance(result, dict) + assert "test" in result + assert isinstance(result["test"], dict) + assert result["test"]["slug"] == "test" + assert result["test"]["version"] == "1.0.0" + assert result["test"]["hassio_api"] is False + assert result["test"]["supervisor_api"] is False + assert result["test"]["hassio_role"] == "default" + assert result["test"]["supervisor_role"] == "default" + + +@pytest.mark.usefixtures("mock_all") +async def test_get_addons_list(hass: HomeAssistant) -> None: + """Test get_addons_list returns a list of serialized dicts.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_addons_list(hass) + assert isinstance(result, list) + assert all(isinstance(addon, dict) for addon in result) + slugs = {addon["slug"] for addon in result} + assert "test" in slugs + assert "test2" in slugs + + +@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default") +async def test_get_addons_stats(hass: HomeAssistant) -> None: + """Test get_addons_stats returns serialized dicts, not model objects. + + Both test addons are STOPPED in mock_all so no addon stats are fetched; + the result is an empty dict which is the correct return type. + """ + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_addons_stats(hass) + assert isinstance(result, dict) + # All values must be plain dicts, never AddonsStats model objects + for stats in result.values(): + assert isinstance(stats, dict) + + +@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default") +async def test_get_core_stats(hass: HomeAssistant) -> None: + """Test get_core_stats returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Stats entities subscribe during setup and trigger a debounced refresh + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + result = get_core_stats(hass) + assert isinstance(result, dict) + assert result["cpu_percent"] == 0.99 + assert result["memory_percent"] == 4.59 + + +@pytest.mark.usefixtures("mock_all", "entity_registry_enabled_by_default") +async def test_get_supervisor_stats(hass: HomeAssistant) -> None: + """Test get_supervisor_stats returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Stats entities subscribe during setup and trigger a debounced refresh + async_fire_time_changed( + hass, dt_util.now() + timedelta(seconds=REQUEST_REFRESH_DELAY) + ) + await hass.async_block_till_done() + + result = get_supervisor_stats(hass) + assert isinstance(result, dict) + assert result["cpu_percent"] == 0.99 + assert result["memory_percent"] == 4.59 + + +@pytest.mark.usefixtures("mock_all") +async def test_get_os_info(hass: HomeAssistant) -> None: + """Test get_os_info returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_os_info(hass) + assert isinstance(result, dict) + assert result["version"] == "1.0.0" + assert result["version_latest"] == "1.0.0" + assert result["update_available"] is False + + +@pytest.mark.usefixtures("mock_all") +async def test_get_core_info(hass: HomeAssistant) -> None: + """Test get_core_info returns serialized dict with expected values.""" + with patch.dict(os.environ, MOCK_ENVIRON): + config_entry = MockConfigEntry(domain=DOMAIN, data={}, unique_id=DOMAIN) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = get_core_info(hass) + assert isinstance(result, dict) + assert result["version"] == "1.0.0" + assert result["version_latest"] == "1.0.0" + assert result["image"] == "homeassistant" diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index ce553beb01a1f..ab947e5bbf9a1 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -251,6 +251,13 @@ async def test_ip_ban_manager_never_started( "os_info", "store_info", "supervisor_info", + "homeassistant_info", + "host_info", + "network_info", + "addons_list", + "addon_info", + "homeassistant_stats", + "supervisor_stats", "ingress_panels", ) async def test_access_from_supervisor_ip( From 2ed550c2c9b9cc96e9ecabf557d57f620fad858c Mon Sep 17 00:00:00 2001 From: Steve Syrell Date: Mon, 4 May 2026 11:55:41 -0700 Subject: [PATCH 11/22] Bump Insteon-panel to 0.6.2 (#169757) --- CODEOWNERS | 4 ++-- homeassistant/components/insteon/manifest.json | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index d4557e226b217..715903bcffe0a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -851,8 +851,8 @@ CLAUDE.md @home-assistant/core /tests/components/input_select/ @home-assistant/core /homeassistant/components/input_text/ @home-assistant/core /tests/components/input_text/ @home-assistant/core -/homeassistant/components/insteon/ @teharris1 -/tests/components/insteon/ @teharris1 +/homeassistant/components/insteon/ @teharris1 @ssyrell +/tests/components/insteon/ @teharris1 @ssyrell /homeassistant/components/integration/ @dgomes /tests/components/integration/ @dgomes /homeassistant/components/intelliclima/ @dvdinth diff --git a/homeassistant/components/insteon/manifest.json b/homeassistant/components/insteon/manifest.json index 32dcdf38f096b..1face9fdfbbf9 100644 --- a/homeassistant/components/insteon/manifest.json +++ b/homeassistant/components/insteon/manifest.json @@ -2,7 +2,7 @@ "domain": "insteon", "name": "Insteon", "after_dependencies": ["panel_custom"], - "codeowners": ["@teharris1"], + "codeowners": ["@teharris1", "@ssyrell"], "config_flow": true, "dependencies": ["http", "usb", "websocket_api"], "dhcp": [ @@ -19,7 +19,7 @@ "loggers": ["pyinsteon", "pypubsub"], "requirements": [ "pyinsteon==1.6.4", - "insteon-frontend-home-assistant==0.6.1" + "insteon-frontend-home-assistant==0.6.2" ], "single_config_entry": true, "usb": [ diff --git a/requirements_all.txt b/requirements_all.txt index 9e0bc9da8dfab..2fc089fe77d75 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1344,7 +1344,7 @@ infrared-protocols==2.0.0 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.6.1 +insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire intellifire4py==4.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index d7a33c39d2b7b..ebf1972574582 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1196,7 +1196,7 @@ infrared-protocols==2.0.0 inkbird-ble==1.1.1 # homeassistant.components.insteon -insteon-frontend-home-assistant==0.6.1 +insteon-frontend-home-assistant==0.6.2 # homeassistant.components.intellifire intellifire4py==4.4.0 From 6319b3b4ef37d0d35a516391273337ff5d60277d Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 May 2026 20:59:28 +0200 Subject: [PATCH 12/22] Raise repairs on platform setup for command_line (#153565) Co-authored-by: Copilot --- .../components/command_line/binary_sensor.py | 7 ++++++- .../components/command_line/cover.py | 9 +++++++-- .../components/command_line/notify.py | 10 +++++++--- .../components/command_line/sensor.py | 8 +++++++- .../components/command_line/strings.json | 6 ++++++ .../components/command_line/switch.py | 13 +++++++++++-- .../components/command_line/utils.py | 19 ++++++++++++++++++- .../command_line/test_binary_sensor.py | 14 ++++++++++++-- tests/components/command_line/test_cover.py | 10 ++++++++-- tests/components/command_line/test_notify.py | 9 ++++++++- tests/components/command_line/test_sensor.py | 11 +++++++++-- tests/components/command_line/test_switch.py | 10 ++++++++-- 12 files changed, 107 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/command_line/binary_sensor.py b/homeassistant/components/command_line/binary_sensor.py index a00a0f7c5ddae..3c620b21c8307 100644 --- a/homeassistant/components/command_line/binary_sensor.py +++ b/homeassistant/components/command_line/binary_sensor.py @@ -3,7 +3,10 @@ import asyncio from datetime import datetime, timedelta -from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.components.binary_sensor import ( + DOMAIN as BINARY_SENSOR_DOMAIN, + BinarySensorEntity, +) from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -25,6 +28,7 @@ from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS from .sensor import CommandSensorData +from .utils import create_platform_yaml_not_supported_issue DEFAULT_NAME = "Binary Command Sensor" DEFAULT_PAYLOAD_ON = "ON" @@ -41,6 +45,7 @@ async def async_setup_platform( ) -> None: """Set up the Command line Binary Sensor.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, BINARY_SENSOR_DOMAIN) return binary_sensor_config = discovery_info diff --git a/homeassistant/components/command_line/cover.py b/homeassistant/components/command_line/cover.py index efecbeee634c7..76e7f561c1f69 100644 --- a/homeassistant/components/command_line/cover.py +++ b/homeassistant/components/command_line/cover.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from homeassistant.components.cover import CoverEntity +from homeassistant.components.cover import DOMAIN as COVER_DOMAIN, CoverEntity from homeassistant.const import ( CONF_COMMAND_CLOSE, CONF_COMMAND_OPEN, @@ -26,7 +26,11 @@ from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS -from .utils import async_call_shell_with_timeout, async_check_output_or_log +from .utils import ( + async_call_shell_with_timeout, + async_check_output_or_log, + create_platform_yaml_not_supported_issue, +) SCAN_INTERVAL = timedelta(seconds=15) @@ -39,6 +43,7 @@ async def async_setup_platform( ) -> None: """Set up cover controlled by shell commands.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, COVER_DOMAIN) return covers = [] diff --git a/homeassistant/components/command_line/notify.py b/homeassistant/components/command_line/notify.py index 5ef92e2118e60..e63046a1c8379 100644 --- a/homeassistant/components/command_line/notify.py +++ b/homeassistant/components/command_line/notify.py @@ -4,25 +4,29 @@ import subprocess from typing import Any -from homeassistant.components.notify import BaseNotificationService +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + BaseNotificationService, +) from homeassistant.const import CONF_COMMAND from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.process import kill_subprocess from .const import CONF_COMMAND_TIMEOUT, LOGGER -from .utils import render_template_args +from .utils import create_platform_yaml_not_supported_issue, render_template_args _LOGGER = logging.getLogger(__name__) -def get_service( +async def async_get_service( hass: HomeAssistant, config: ConfigType, discovery_info: DiscoveryInfoType | None = None, ) -> CommandLineNotificationService | None: """Get the Command Line notification service.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, NOTIFY_DOMAIN) return None notify_config = discovery_info diff --git a/homeassistant/components/command_line/sensor.py b/homeassistant/components/command_line/sensor.py index 45b3e754f0900..2d77d9e6086af 100644 --- a/homeassistant/components/command_line/sensor.py +++ b/homeassistant/components/command_line/sensor.py @@ -8,6 +8,7 @@ from jsonpath import jsonpath +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( CONF_COMMAND, CONF_NAME, @@ -32,7 +33,11 @@ LOGGER, TRIGGER_ENTITY_OPTIONS, ) -from .utils import async_check_output_or_log, render_template_args +from .utils import ( + async_check_output_or_log, + create_platform_yaml_not_supported_issue, + render_template_args, +) DEFAULT_NAME = "Command Sensor" @@ -47,6 +52,7 @@ async def async_setup_platform( ) -> None: """Set up the Command Sensor.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, SENSOR_DOMAIN) return sensor_config = discovery_info diff --git a/homeassistant/components/command_line/strings.json b/homeassistant/components/command_line/strings.json index 6497fdcf98d21..7e569411df6b5 100644 --- a/homeassistant/components/command_line/strings.json +++ b/homeassistant/components/command_line/strings.json @@ -1,4 +1,10 @@ { + "issues": { + "platform_yaml_not_supported": { + "description": "Platform YAML setup is not supported.\nChange from configuring it using the `{platform}:` key to using the `command_line:` key directly in configuration.yaml and restart Home Assistant to resolve the issue.\nTo see the detailed documentation, select Learn more.", + "title": "Platform YAML is not supported in Command Line" + } + }, "services": { "reload": { "description": "Reloads command line configuration from the YAML-configuration.", diff --git a/homeassistant/components/command_line/switch.py b/homeassistant/components/command_line/switch.py index 2408467a90729..c546e147c5704 100644 --- a/homeassistant/components/command_line/switch.py +++ b/homeassistant/components/command_line/switch.py @@ -4,7 +4,11 @@ from datetime import datetime, timedelta from typing import TYPE_CHECKING, Any -from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchEntity +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + ENTITY_ID_FORMAT, + SwitchEntity, +) from homeassistant.const import ( CONF_COMMAND_OFF, CONF_COMMAND_ON, @@ -25,7 +29,11 @@ from homeassistant.util import dt as dt_util, slugify from .const import CONF_COMMAND_TIMEOUT, LOGGER, TRIGGER_ENTITY_OPTIONS -from .utils import async_call_shell_with_timeout, async_check_output_or_log +from .utils import ( + async_call_shell_with_timeout, + async_check_output_or_log, + create_platform_yaml_not_supported_issue, +) SCAN_INTERVAL = timedelta(seconds=30) @@ -38,6 +46,7 @@ async def async_setup_platform( ) -> None: """Find and return switches controlled by shell commands.""" if not discovery_info: + create_platform_yaml_not_supported_issue(hass, SWITCH_DOMAIN) return switches = [] diff --git a/homeassistant/components/command_line/utils.py b/homeassistant/components/command_line/utils.py index 53ce62ed8b7da..3e99f245bb2a9 100644 --- a/homeassistant/components/command_line/utils.py +++ b/homeassistant/components/command_line/utils.py @@ -4,9 +4,10 @@ from homeassistant.core import HomeAssistant from homeassistant.exceptions import TemplateError +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.template import Template -from .const import LOGGER +from .const import DOMAIN, LOGGER _EXEC_FAILED_CODE = 127 @@ -91,3 +92,19 @@ def render_template_args(hass: HomeAssistant, command: str) -> str | None: LOGGER.debug("Running command: %s", command) return command + + +def create_platform_yaml_not_supported_issue( + hass: HomeAssistant, platform_domain: str +) -> None: + """Create an issue when platform yaml is used.""" + async_create_issue( + hass, + DOMAIN, + f"{platform_domain}_platform_yaml_not_supported", + is_fixable=False, + severity=IssueSeverity.ERROR, + translation_key="platform_yaml_not_supported", + translation_placeholders={"platform": platform_domain}, + learn_more_url="https://www.home-assistant.io/integrations/command_line/", + ) diff --git a/tests/components/command_line/test_binary_sensor.py b/tests/components/command_line/test_binary_sensor.py index fdab9dffdcc13..c0c0fe421ed8d 100644 --- a/tests/components/command_line/test_binary_sensor.py +++ b/tests/components/command_line/test_binary_sensor.py @@ -9,6 +9,7 @@ import pytest from homeassistant import setup +from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN from homeassistant.components.command_line.binary_sensor import CommandBinarySensor from homeassistant.components.command_line.const import DOMAIN from homeassistant.components.homeassistant import ( @@ -17,7 +18,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run @@ -54,7 +55,9 @@ async def test_setup_integration_yaml( assert entity_state.name == "Test" -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: +async def test_setup_platform_yaml( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test setting up the platform with platform yaml.""" await setup.async_setup_component( hass, @@ -71,6 +74,13 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + issue = issue_registry.async_get_issue( + DOMAIN, "binary_sensor_platform_yaml_not_supported" + ) + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"platform": BINARY_SENSOR_DOMAIN} + @pytest.mark.parametrize( "get_config", diff --git a/tests/components/command_line/test_cover.py b/tests/components/command_line/test_cover.py index 33d3d9b50898c..576f873826078 100644 --- a/tests/components/command_line/test_cover.py +++ b/tests/components/command_line/test_cover.py @@ -29,7 +29,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run @@ -37,7 +37,9 @@ from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: +async def test_setup_platform_yaml( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test setting up the platform with platform yaml.""" await setup.async_setup_component( hass, @@ -53,6 +55,10 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + issue = issue_registry.async_get_issue(DOMAIN, "cover_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"platform": COVER_DOMAIN} async def test_no_poll_when_cover_has_no_command_state(hass: HomeAssistant) -> None: diff --git a/tests/components/command_line/test_notify.py b/tests/components/command_line/test_notify.py index f14d450cdef34..d881f58d907a2 100644 --- a/tests/components/command_line/test_notify.py +++ b/tests/components/command_line/test_notify.py @@ -12,9 +12,12 @@ from homeassistant.components.command_line import DOMAIN from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: +async def test_setup_platform_yaml( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test setting up the platform with platform yaml.""" await setup.async_setup_component( hass, @@ -30,6 +33,10 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + issue = issue_registry.async_get_issue(DOMAIN, "notify_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"platform": NOTIFY_DOMAIN} @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_sensor.py b/tests/components/command_line/test_sensor.py index a9c4c901eba92..1c22e483e54eb 100644 --- a/tests/components/command_line/test_sensor.py +++ b/tests/components/command_line/test_sensor.py @@ -15,9 +15,10 @@ DOMAIN as HA_DOMAIN, SERVICE_UPDATE_ENTITY, ) +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, STATE_UNKNOWN from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run @@ -25,7 +26,9 @@ from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: +async def test_setup_platform_yaml( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test setting up the platform with platform yaml.""" await setup.async_setup_component( hass, @@ -41,6 +44,10 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + issue = issue_registry.async_get_issue(DOMAIN, "sensor_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"platform": SENSOR_DOMAIN} @pytest.mark.parametrize( diff --git a/tests/components/command_line/test_switch.py b/tests/components/command_line/test_switch.py index e0a0d9bc4e536..12152e0d5b32d 100644 --- a/tests/components/command_line/test_switch.py +++ b/tests/components/command_line/test_switch.py @@ -27,7 +27,7 @@ STATE_UNAVAILABLE, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import entity_registry as er, issue_registry as ir from homeassistant.util import dt as dt_util from . import mock_asyncio_subprocess_run @@ -35,7 +35,9 @@ from tests.common import async_fire_time_changed -async def test_setup_platform_yaml(hass: HomeAssistant) -> None: +async def test_setup_platform_yaml( + hass: HomeAssistant, issue_registry: ir.IssueRegistry +) -> None: """Test setting up the platform with platform yaml.""" await setup.async_setup_component( hass, @@ -51,6 +53,10 @@ async def test_setup_platform_yaml(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() assert len(hass.states.async_all()) == 0 + issue = issue_registry.async_get_issue(DOMAIN, "switch_platform_yaml_not_supported") + assert issue is not None + assert issue.severity == ir.IssueSeverity.ERROR + assert issue.translation_placeholders == {"platform": SWITCH_DOMAIN} async def test_state_integration_yaml(hass: HomeAssistant) -> None: From 1beeecdf04e0674dca983421c2fef05189262d39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kamil=20Bregu=C5=82a?= Date: Mon, 4 May 2026 21:02:15 +0200 Subject: [PATCH 13/22] Use SensorDeviceClass.UPTIME in WLED (#169708) Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com> --- homeassistant/components/wled/sensor.py | 2 +- homeassistant/components/wled/strings.json | 3 --- tests/components/wled/snapshots/test_sensor.ambr | 4 ++-- 3 files changed, 3 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/wled/sensor.py b/homeassistant/components/wled/sensor.py index 772f76767a988..ab8ae2ac5c0e5 100644 --- a/homeassistant/components/wled/sensor.py +++ b/homeassistant/components/wled/sensor.py @@ -68,7 +68,7 @@ class WLEDSensorEntityDescription(SensorEntityDescription): WLEDSensorEntityDescription( key="uptime", translation_key="uptime", - device_class=SensorDeviceClass.TIMESTAMP, + device_class=SensorDeviceClass.UPTIME, entity_category=EntityCategory.DIAGNOSTIC, entity_registry_enabled_default=False, value_fn=lambda device: utcnow() - device.info.uptime, diff --git a/homeassistant/components/wled/strings.json b/homeassistant/components/wled/strings.json index 2329636d068be..5a2732c774547 100644 --- a/homeassistant/components/wled/strings.json +++ b/homeassistant/components/wled/strings.json @@ -98,9 +98,6 @@ "ip": { "name": "IP" }, - "uptime": { - "name": "Uptime" - }, "wifi_bssid": { "name": "Wi-Fi BSSID" }, diff --git a/tests/components/wled/snapshots/test_sensor.ambr b/tests/components/wled/snapshots/test_sensor.ambr index a71d1688d86a5..01894d7196be0 100644 --- a/tests/components/wled/snapshots/test_sensor.ambr +++ b/tests/components/wled/snapshots/test_sensor.ambr @@ -296,7 +296,7 @@ 'object_id_base': 'Uptime', 'options': dict({ }), - 'original_device_class': , + 'original_device_class': , 'original_icon': None, 'original_name': 'Uptime', 'platform': 'wled', @@ -311,7 +311,7 @@ # name: test_snapshots[sensor.wled_rgb_light_uptime-state] StateSnapshot({ 'attributes': ReadOnlyDict({ - 'device_class': 'timestamp', + 'device_class': 'uptime', 'friendly_name': 'WLED RGB Light Uptime', }), 'context': , From 6633f16d1318c557e202b33086a0a99dca9754fa Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Mon, 4 May 2026 21:07:16 +0200 Subject: [PATCH 14/22] Add system health to Portainer (#169698) --- .../components/portainer/strings.json | 5 ++ .../components/portainer/system_health.py | 28 +++++++++ .../portainer/test_system_health.py | 59 +++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 homeassistant/components/portainer/system_health.py create mode 100644 tests/components/portainer/test_system_health.py diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json index c0fffcf504b1a..abb215e1455ca 100644 --- a/homeassistant/components/portainer/strings.json +++ b/homeassistant/components/portainer/strings.json @@ -236,5 +236,10 @@ }, "name": "Prune unused images" } + }, + "system_health": { + "info": { + "can_reach_server": "Reach Portainer server" + } } } diff --git a/homeassistant/components/portainer/system_health.py b/homeassistant/components/portainer/system_health.py new file mode 100644 index 0000000000000..7fda712884bc2 --- /dev/null +++ b/homeassistant/components/portainer/system_health.py @@ -0,0 +1,28 @@ +"""Provide info to system health.""" + +from typing import Any + +from homeassistant.components import system_health +from homeassistant.const import CONF_URL +from homeassistant.core import HomeAssistant, callback + +from .const import DOMAIN + + +@callback +def async_register( + hass: HomeAssistant, register: system_health.SystemHealthRegistration +) -> None: + """Register system health callbacks.""" + register.async_register_info(system_health_info) + + +async def system_health_info(hass: HomeAssistant) -> dict[str, Any]: + """Get info for the info page.""" + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + + return { + "can_reach_server": system_health.async_check_can_reach_url( + hass, f"{config_entry.data[CONF_URL].rstrip('/')}/api/system/status" + ), + } diff --git a/tests/components/portainer/test_system_health.py b/tests/components/portainer/test_system_health.py new file mode 100644 index 0000000000000..dad64e707fd9b --- /dev/null +++ b/tests/components/portainer/test_system_health.py @@ -0,0 +1,59 @@ +"""Test Portainer system health.""" + +import asyncio +from unittest.mock import AsyncMock + +from aiohttp import ClientError + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from . import setup_integration + +from tests.common import MockConfigEntry, get_system_health_info +from tests.test_util.aiohttp import AiohttpClientMocker + +MOCK_HEALTH_URL = "https://127.0.0.1:9000/api/system/status" + + +async def test_system_health( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + mock_portainer_client: AsyncMock, +) -> None: + """Test system health when server is reachable.""" + aioclient_mock.get(MOCK_HEALTH_URL, text="ok") + + assert await async_setup_component(hass, "system_health", {}) + await setup_integration(hass, mock_config_entry) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info["can_reach_server"] == "ok" + + +async def test_system_health_failed_connect( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, + mock_portainer_client: AsyncMock, +) -> None: + """Test system health when server is unreachable.""" + aioclient_mock.get(MOCK_HEALTH_URL, exc=ClientError) + + assert await async_setup_component(hass, "system_health", {}) + await setup_integration(hass, mock_config_entry) + + info = await get_system_health_info(hass, DOMAIN) + + for key, val in info.items(): + if asyncio.iscoroutine(val): + info[key] = await val + + assert info["can_reach_server"] == {"error": "unreachable", "type": "failed"} From 553ba5e7ab592b4fc841776231b83b9864c35761 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 4 May 2026 21:10:06 +0200 Subject: [PATCH 15/22] Add binary sensor to Nord Pool (#169684) --- .../components/nordpool/binary_sensor.py | 81 +++++++ homeassistant/components/nordpool/const.py | 2 +- .../components/nordpool/coordinator.py | 5 + .../components/nordpool/strings.json | 5 + tests/components/nordpool/conftest.py | 22 +- .../snapshots/test_binary_sensor.ambr | 201 ++++++++++++++++++ .../nordpool/snapshots/test_sensor.ambr | 192 ++++++++--------- .../components/nordpool/test_binary_sensor.py | 69 ++++++ tests/components/nordpool/test_sensor.py | 6 +- 9 files changed, 480 insertions(+), 103 deletions(-) create mode 100644 homeassistant/components/nordpool/binary_sensor.py create mode 100644 tests/components/nordpool/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/nordpool/test_binary_sensor.py diff --git a/homeassistant/components/nordpool/binary_sensor.py b/homeassistant/components/nordpool/binary_sensor.py new file mode 100644 index 0000000000000..76235e802e116 --- /dev/null +++ b/homeassistant/components/nordpool/binary_sensor.py @@ -0,0 +1,81 @@ +"""Binary sensor platform for Nord Pool integration.""" + +from collections.abc import Callable +from dataclasses import dataclass + +from homeassistant.components.binary_sensor import ( + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.components.sensor import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import NordPoolConfigEntry +from .const import CONF_AREAS +from .coordinator import NordPoolDataUpdateCoordinator +from .entity import NordpoolBaseEntity + +PARALLEL_UPDATES = 0 + + +def get_tomorrow_price_available( + entity: NordpoolPriceBinarySensor, +) -> bool: + """Return tomorrow price availability.""" + data = entity.coordinator.get_data_tomorrow() + return bool(data and data.entries and entity.area in data.entries[0].entry) + + +@dataclass(frozen=True, kw_only=True) +class NordpoolBinarySensorEntityDescription(BinarySensorEntityDescription): + """Describes Nord Pool binary sensor entity.""" + + value_fn: Callable[[NordpoolPriceBinarySensor], bool | None] + + +BINARY_SENSOR_TYPES: tuple[NordpoolBinarySensorEntityDescription, ...] = ( + NordpoolBinarySensorEntityDescription( + key="tomorrow_price_available", + translation_key="tomorrow_price_available", + value_fn=get_tomorrow_price_available, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: NordPoolConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Nord Pool binary sensor platform.""" + + coordinator = entry.runtime_data + areas = coordinator.config_entry.data[CONF_AREAS] + + async_add_entities( + NordpoolPriceBinarySensor(coordinator, description, area) + for description in BINARY_SENSOR_TYPES + for area in areas + ) + + +class NordpoolPriceBinarySensor(NordpoolBaseEntity, BinarySensorEntity): + """Representation of a Nord Pool binary sensor.""" + + entity_description: NordpoolBinarySensorEntityDescription + + def __init__( + self, + coordinator: NordPoolDataUpdateCoordinator, + entity_description: NordpoolBinarySensorEntityDescription, + area: str, + ) -> None: + """Initiate Nord Pool binary sensor.""" + super().__init__(coordinator, entity_description, area) + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.value_fn(self) diff --git a/homeassistant/components/nordpool/const.py b/homeassistant/components/nordpool/const.py index 1fd3009321b82..cb0f3f30b07b1 100644 --- a/homeassistant/components/nordpool/const.py +++ b/homeassistant/components/nordpool/const.py @@ -8,7 +8,7 @@ DEFAULT_SCAN_INTERVAL = 60 DOMAIN = "nordpool" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] DEFAULT_NAME = "Nord Pool" CONF_AREAS = "areas" diff --git a/homeassistant/components/nordpool/coordinator.py b/homeassistant/components/nordpool/coordinator.py index f85c06e8d66b1..3372e6cf1e4a7 100644 --- a/homeassistant/components/nordpool/coordinator.py +++ b/homeassistant/components/nordpool/coordinator.py @@ -164,3 +164,8 @@ def get_data_current_day(self) -> DeliveryPeriodData: """Return the current day data.""" current_day = dt_util.now().date() return self.data.entries[current_day] + + def get_data_tomorrow(self) -> DeliveryPeriodData | None: + """Return tomorrow's day data if available.""" + tomorrow = dt_util.now().date() + timedelta(days=1) + return self.data.entries.get(tomorrow) diff --git a/homeassistant/components/nordpool/strings.json b/homeassistant/components/nordpool/strings.json index 88706a9fbbdc0..085e342678bb1 100644 --- a/homeassistant/components/nordpool/strings.json +++ b/homeassistant/components/nordpool/strings.json @@ -32,6 +32,11 @@ } }, "entity": { + "binary_sensor": { + "tomorrow_price_available": { + "name": "Tomorrow price available" + } + }, "sensor": { "block_average": { "name": "{block} average" diff --git a/tests/components/nordpool/conftest.py b/tests/components/nordpool/conftest.py index 7b3187403b6e3..dd717acc7cf48 100644 --- a/tests/components/nordpool/conftest.py +++ b/tests/components/nordpool/conftest.py @@ -7,18 +7,29 @@ from pynordpool import API, NordPoolClient import pytest -from homeassistant.components.nordpool.const import DOMAIN +from homeassistant.components.nordpool.const import DOMAIN, PLATFORMS from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import Platform from homeassistant.core import HomeAssistant from . import ENTRY_CONFIG -from tests.common import MockConfigEntry, load_fixture +from tests.common import MockConfigEntry, load_fixture, patch from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.fixture(name="load_platforms") +async def patch_platform_constant() -> list[Platform]: + """Return list of platforms to load.""" + return PLATFORMS + + @pytest.fixture -async def load_int(hass: HomeAssistant, get_client: NordPoolClient) -> MockConfigEntry: +async def load_int( + hass: HomeAssistant, + get_client: NordPoolClient, + load_platforms: list[Platform], +) -> MockConfigEntry: """Set up the Nord Pool integration in Home Assistant.""" config_entry = MockConfigEntry( domain=DOMAIN, @@ -28,8 +39,9 @@ async def load_int(hass: HomeAssistant, get_client: NordPoolClient) -> MockConfi config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() + with patch("homeassistant.components.nordpool.PLATFORMS", load_platforms): + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() return config_entry diff --git a/tests/components/nordpool/snapshots/test_binary_sensor.ambr b/tests/components/nordpool/snapshots/test_binary_sensor.ambr new file mode 100644 index 0000000000000..db1aae7276377 --- /dev/null +++ b/tests/components/nordpool/snapshots/test_binary_sensor.ambr @@ -0,0 +1,201 @@ +# serializer version: 1 +# name: test_binary_sensor_off[load_platforms0][binary_sensor.nord_pool_se3_tomorrow_price_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nord_pool_se3_tomorrow_price_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tomorrow price available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tomorrow price available', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tomorrow_price_available', + 'unique_id': 'SE3-tomorrow_price_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_off[load_platforms0][binary_sensor.nord_pool_se3_tomorrow_price_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Tomorrow price available', + }), + 'context': , + 'entity_id': 'binary_sensor.nord_pool_se3_tomorrow_price_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_off[load_platforms0][binary_sensor.nord_pool_se4_tomorrow_price_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nord_pool_se4_tomorrow_price_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tomorrow price available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tomorrow price available', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tomorrow_price_available', + 'unique_id': 'SE4-tomorrow_price_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_off[load_platforms0][binary_sensor.nord_pool_se4_tomorrow_price_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Tomorrow price available', + }), + 'context': , + 'entity_id': 'binary_sensor.nord_pool_se4_tomorrow_price_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_binary_sensor_on[load_platforms0][binary_sensor.nord_pool_se3_tomorrow_price_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nord_pool_se3_tomorrow_price_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tomorrow price available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tomorrow price available', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tomorrow_price_available', + 'unique_id': 'SE3-tomorrow_price_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_on[load_platforms0][binary_sensor.nord_pool_se3_tomorrow_price_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE3 Tomorrow price available', + }), + 'context': , + 'entity_id': 'binary_sensor.nord_pool_se3_tomorrow_price_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_binary_sensor_on[load_platforms0][binary_sensor.nord_pool_se4_tomorrow_price_available-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.nord_pool_se4_tomorrow_price_available', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Tomorrow price available', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tomorrow price available', + 'platform': 'nordpool', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tomorrow_price_available', + 'unique_id': 'SE4-tomorrow_price_available', + 'unit_of_measurement': None, + }) +# --- +# name: test_binary_sensor_on[load_platforms0][binary_sensor.nord_pool_se4_tomorrow_price_available-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Nord Pool SE4 Tomorrow price available', + }), + 'context': , + 'entity_id': 'binary_sensor.nord_pool_se4_tomorrow_price_available', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/nordpool/snapshots/test_sensor.ambr b/tests/components/nordpool/snapshots/test_sensor.ambr index aead415160217..adfc24a3f62e3 100644 --- a/tests/components/nordpool/snapshots/test_sensor.ambr +++ b/tests/components/nordpool/snapshots/test_sensor.ambr @@ -1,5 +1,5 @@ # serializer version: 1 -# name: test_sensor[sensor.nord_pool_se3_currency-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_currency-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -36,7 +36,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_currency-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_currency-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Currency', @@ -49,7 +49,7 @@ 'state': 'SEK', }) # --- -# name: test_sensor[sensor.nord_pool_se3_current_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_current_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -91,7 +91,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_current_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_current_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Current price', @@ -106,7 +106,7 @@ 'state': '1.99796', }) # --- -# name: test_sensor[sensor.nord_pool_se3_daily_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_daily_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -148,7 +148,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_daily_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_daily_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Daily average', @@ -163,7 +163,7 @@ 'state': '1.03398', }) # --- -# name: test_sensor[sensor.nord_pool_se3_exchange_rate-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_exchange_rate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -202,7 +202,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_exchange_rate-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_exchange_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Exchange rate', @@ -216,7 +216,7 @@ 'state': '11.05186', }) # --- -# name: test_sensor[sensor.nord_pool_se3_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -256,7 +256,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'end': '2025-10-01T17:15:00+00:00', @@ -272,7 +272,7 @@ 'state': '3.82803', }) # --- -# name: test_sensor[sensor.nord_pool_se3_last_updated-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_last_updated-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -309,7 +309,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_last_updated-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_last_updated-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -323,7 +323,7 @@ 'state': '2025-09-30T12:08:16+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -363,7 +363,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'end': '2025-10-01T02:15:00+00:00', @@ -379,7 +379,7 @@ 'state': '0.44196', }) # --- -# name: test_sensor[sensor.nord_pool_se3_next_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_next_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -419,7 +419,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_next_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_next_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Next price', @@ -433,7 +433,7 @@ 'state': '1.42403', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -475,7 +475,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 1 average', @@ -490,7 +490,7 @@ 'state': '0.74593', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -532,7 +532,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 1 highest price', @@ -547,7 +547,7 @@ 'state': '1.80996', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -589,7 +589,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 1 lowest price', @@ -604,7 +604,7 @@ 'state': '0.44196', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -641,7 +641,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -655,7 +655,7 @@ 'state': '2025-09-30T22:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -692,7 +692,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_1_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_1_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -706,7 +706,7 @@ 'state': '2025-10-01T06:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -748,7 +748,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 2 average', @@ -763,7 +763,7 @@ 'state': '1.05461', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -805,7 +805,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 2 highest price', @@ -820,7 +820,7 @@ 'state': '1.99796', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -862,7 +862,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Off-peak 2 lowest price', @@ -877,7 +877,7 @@ 'state': '0.78568', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -914,7 +914,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -928,7 +928,7 @@ 'state': '2025-10-01T18:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -965,7 +965,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_off_peak_2_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_off_peak_2_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -979,7 +979,7 @@ 'state': '2025-10-01T22:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1021,7 +1021,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Peak average', @@ -1036,7 +1036,7 @@ 'state': '1.21913', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1078,7 +1078,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Peak highest price', @@ -1093,7 +1093,7 @@ 'state': '3.82803', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1135,7 +1135,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Peak lowest price', @@ -1150,7 +1150,7 @@ 'state': '0.60774', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1187,7 +1187,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -1201,7 +1201,7 @@ 'state': '2025-10-01T06:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1238,7 +1238,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se3_peak_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_peak_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -1252,7 +1252,7 @@ 'state': '2025-10-01T18:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se3_previous_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_previous_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1292,7 +1292,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se3_previous_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se3_previous_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE3 Previous price', @@ -1306,7 +1306,7 @@ 'state': '2.30807', }) # --- -# name: test_sensor[sensor.nord_pool_se4_currency-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_currency-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1343,7 +1343,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_currency-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_currency-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Currency', @@ -1356,7 +1356,7 @@ 'state': 'SEK', }) # --- -# name: test_sensor[sensor.nord_pool_se4_current_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_current_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1398,7 +1398,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_current_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_current_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Current price', @@ -1413,7 +1413,7 @@ 'state': '0.0', }) # --- -# name: test_sensor[sensor.nord_pool_se4_daily_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_daily_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1455,7 +1455,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_daily_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_daily_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Daily average', @@ -1470,7 +1470,7 @@ 'state': '1.18078', }) # --- -# name: test_sensor[sensor.nord_pool_se4_exchange_rate-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_exchange_rate-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1509,7 +1509,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_exchange_rate-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_exchange_rate-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Exchange rate', @@ -1523,7 +1523,7 @@ 'state': '11.05186', }) # --- -# name: test_sensor[sensor.nord_pool_se4_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1563,7 +1563,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'end': '2025-10-01T17:15:00+00:00', @@ -1579,7 +1579,7 @@ 'state': '4.44274', }) # --- -# name: test_sensor[sensor.nord_pool_se4_last_updated-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_last_updated-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1616,7 +1616,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_last_updated-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_last_updated-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -1630,7 +1630,7 @@ 'state': '2025-09-30T12:08:16+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1670,7 +1670,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'end': '2025-10-01T18:15:00+00:00', @@ -1686,7 +1686,7 @@ 'state': '0.0', }) # --- -# name: test_sensor[sensor.nord_pool_se4_next_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_next_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1726,7 +1726,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_next_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_next_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Next price', @@ -1740,7 +1740,7 @@ 'state': '1.64617', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1782,7 +1782,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 1 average', @@ -1797,7 +1797,7 @@ 'state': '0.86099', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1839,7 +1839,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 1 highest price', @@ -1854,7 +1854,7 @@ 'state': '2.02934', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1896,7 +1896,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 1 lowest price', @@ -1911,7 +1911,7 @@ 'state': '0.51546', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1948,7 +1948,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -1962,7 +1962,7 @@ 'state': '2025-09-30T22:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -1999,7 +1999,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_1_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_1_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -2013,7 +2013,7 @@ 'state': '2025-10-01T06:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2055,7 +2055,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 2 average', @@ -2070,7 +2070,7 @@ 'state': '1.21907', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2112,7 +2112,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 2 highest price', @@ -2127,7 +2127,7 @@ 'state': '2.31216', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2169,7 +2169,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Off-peak 2 lowest price', @@ -2184,7 +2184,7 @@ 'state': '0.91222', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2221,7 +2221,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -2235,7 +2235,7 @@ 'state': '2025-10-01T18:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2272,7 +2272,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_off_peak_2_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_off_peak_2_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -2286,7 +2286,7 @@ 'state': '2025-10-01T22:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_average-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_average-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2328,7 +2328,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_average-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_average-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Peak average', @@ -2343,7 +2343,7 @@ 'state': '1.38122', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_highest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2385,7 +2385,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_highest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_highest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Peak highest price', @@ -2400,7 +2400,7 @@ 'state': '4.44274', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_lowest_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2442,7 +2442,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_lowest_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_lowest_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Peak lowest price', @@ -2457,7 +2457,7 @@ 'state': '0.68312', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_time_from-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_time_from-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2494,7 +2494,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_time_from-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_time_from-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -2508,7 +2508,7 @@ 'state': '2025-10-01T06:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_time_until-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_time_until-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2545,7 +2545,7 @@ 'unit_of_measurement': None, }) # --- -# name: test_sensor[sensor.nord_pool_se4_peak_time_until-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_peak_time_until-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'device_class': 'timestamp', @@ -2559,7 +2559,7 @@ 'state': '2025-10-01T18:00:00+00:00', }) # --- -# name: test_sensor[sensor.nord_pool_se4_previous_price-entry] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_previous_price-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ None, @@ -2599,7 +2599,7 @@ 'unit_of_measurement': 'SEK/kWh', }) # --- -# name: test_sensor[sensor.nord_pool_se4_previous_price-state] +# name: test_sensor[load_platforms0][sensor.nord_pool_se4_previous_price-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'friendly_name': 'Nord Pool SE4 Previous price', diff --git a/tests/components/nordpool/test_binary_sensor.py b/tests/components/nordpool/test_binary_sensor.py new file mode 100644 index 0000000000000..64ff0a9035cd8 --- /dev/null +++ b/tests/components/nordpool/test_binary_sensor.py @@ -0,0 +1,69 @@ +"""The test for the Nord Pool binary sensor platform.""" + +from datetime import timedelta +from http import HTTPStatus + +from freezegun.api import FrozenDateTimeFactory +from pynordpool import API +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import async_fire_time_changed, snapshot_platform +from tests.test_util.aiohttp import AiohttpClientMocker + + +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BINARY_SENSOR]], +) +async def test_binary_sensor_on( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test the Nord Pool sensor.""" + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) + + +@pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.BINARY_SENSOR]], +) +async def test_binary_sensor_off( + hass: HomeAssistant, + load_int: ConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, + aioclient_mock: AiohttpClientMocker, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the Nord Pool sensor.""" + + aioclient_mock.request( + "GET", + url=API + "/DayAheadPrices", + params={ + "date": "2025-10-03", + "market": "DayAhead", + "deliveryArea": "SE3,SE4", + "currency": "SEK", + }, + status=HTTPStatus.NO_CONTENT, + ) + + freezer.tick(timedelta(days=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done(wait_background_tasks=True) + + await snapshot_platform(hass, entity_registry, snapshot, load_int.entry_id) diff --git a/tests/components/nordpool/test_sensor.py b/tests/components/nordpool/test_sensor.py index ea2446de8d35c..ece76552f1321 100644 --- a/tests/components/nordpool/test_sensor.py +++ b/tests/components/nordpool/test_sensor.py @@ -10,7 +10,7 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.config_entries import ConfigEntry -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -20,6 +20,10 @@ @pytest.mark.freeze_time("2025-10-01T18:00:00+00:00") @pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + "load_platforms", + [[Platform.SENSOR]], +) async def test_sensor( hass: HomeAssistant, load_int: ConfigEntry, From f90e9ceb6c7b60d0b6ca3ec2d076cc84b8bea0f0 Mon Sep 17 00:00:00 2001 From: kernelpanic85 <80057019+kernelpanic85@users.noreply.github.com> Date: Mon, 4 May 2026 15:20:04 -0400 Subject: [PATCH 16/22] Add Celsius and Fahrenheit to Smartthings UNITS mapping (#169686) --- homeassistant/components/smartthings/sensor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 384018c84693b..0595cf200938d 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1284,6 +1284,8 @@ class SmartThingsSensorEntityDescription(SensorEntityDescription): UNITS = { "C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT, + "Celsius": UnitOfTemperature.CELSIUS, + "Fahrenheit": UnitOfTemperature.FAHRENHEIT, "ccf": UnitOfVolume.CENTUM_CUBIC_FEET, "lux": LIGHT_LUX, "mG": None, From d0c0f023118d9f6771471da99fbf7af9da8f658e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20Hjelseth=20H=C3=B8yer?= Date: Mon, 4 May 2026 21:21:57 +0200 Subject: [PATCH 17/22] Bump pyTibber to 0.37.3 (#169762) --- 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 774f4d0ee4a0e..7188671911136 100644 --- a/homeassistant/components/tibber/manifest.json +++ b/homeassistant/components/tibber/manifest.json @@ -8,5 +8,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["tibber"], - "requirements": ["pyTibber==0.37.2"] + "requirements": ["pyTibber==0.37.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index 2fc089fe77d75..7a98d7e093859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1941,7 +1941,7 @@ pyRFXtrx==0.31.1 pySDCP==1 # homeassistant.components.tibber -pyTibber==0.37.2 +pyTibber==0.37.3 # homeassistant.components.dlink pyW215==0.8.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ebf1972574582..3bbc4990229a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1684,7 +1684,7 @@ pyHomee==1.3.8 pyRFXtrx==0.31.1 # homeassistant.components.tibber -pyTibber==0.37.2 +pyTibber==0.37.3 # homeassistant.components.dlink pyW215==0.8.0 From 28d65e987ce5198d8f29a4d58e4e09c4258c0424 Mon Sep 17 00:00:00 2001 From: Keilin Bickar Date: Mon, 4 May 2026 15:22:45 -0400 Subject: [PATCH 18/22] bump sense-energy to 0.14.1 (#169761) --- homeassistant/components/emulated_kasa/manifest.json | 2 +- homeassistant/components/sense/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emulated_kasa/manifest.json b/homeassistant/components/emulated_kasa/manifest.json index 2a517aee35929..bc7ed9de58229 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.14.0"] + "requirements": ["sense-energy==0.14.1"] } diff --git a/homeassistant/components/sense/manifest.json b/homeassistant/components/sense/manifest.json index 3816a8c4ff91d..07187066dcde4 100644 --- a/homeassistant/components/sense/manifest.json +++ b/homeassistant/components/sense/manifest.json @@ -21,5 +21,5 @@ "integration_type": "hub", "iot_class": "cloud_polling", "loggers": ["sense_energy"], - "requirements": ["sense-energy==0.14.0"] + "requirements": ["sense-energy==0.14.1"] } diff --git a/requirements_all.txt b/requirements_all.txt index 7a98d7e093859..17a3175ba4de0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2916,7 +2916,7 @@ sendgrid==6.8.2 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.14.0 +sense-energy==0.14.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3bbc4990229a4..30c238e51eab2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2482,7 +2482,7 @@ securetar==2026.4.1 # homeassistant.components.emulated_kasa # homeassistant.components.sense -sense-energy==0.14.0 +sense-energy==0.14.1 # homeassistant.components.sensirion_ble sensirion-ble==0.1.1 From 8c8a8638670fb939ea230f0ab529689e2a261fc0 Mon Sep 17 00:00:00 2001 From: Matthew Gibson <64029882+frogman85978@users.noreply.github.com> Date: Mon, 4 May 2026 16:15:52 -0400 Subject: [PATCH 19/22] Add ptdevices Integration (#156307) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/ptdevices/__init__.py | 46 + .../components/ptdevices/config_flow.py | 118 +++ homeassistant/components/ptdevices/const.py | 4 + .../components/ptdevices/coordinator.py | 88 ++ homeassistant/components/ptdevices/entity.py | 49 ++ homeassistant/components/ptdevices/icons.json | 30 + .../components/ptdevices/manifest.json | 12 + .../components/ptdevices/quality_scale.yaml | 75 ++ homeassistant/components/ptdevices/sensor.py | 203 +++++ .../components/ptdevices/strings.json | 69 ++ 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/ptdevices/__init__.py | 13 + tests/components/ptdevices/conftest.py | 68 ++ .../ptdevices/fixtures/ptdevices_level.json | 86 ++ .../ptdevices/snapshots/test_sensor.ambr | 811 ++++++++++++++++++ .../components/ptdevices/test_config_flow.py | 151 ++++ tests/components/ptdevices/test_sensor.py | 31 + 23 files changed, 1880 insertions(+) create mode 100644 homeassistant/components/ptdevices/__init__.py create mode 100644 homeassistant/components/ptdevices/config_flow.py create mode 100644 homeassistant/components/ptdevices/const.py create mode 100644 homeassistant/components/ptdevices/coordinator.py create mode 100644 homeassistant/components/ptdevices/entity.py create mode 100644 homeassistant/components/ptdevices/icons.json create mode 100644 homeassistant/components/ptdevices/manifest.json create mode 100644 homeassistant/components/ptdevices/quality_scale.yaml create mode 100644 homeassistant/components/ptdevices/sensor.py create mode 100644 homeassistant/components/ptdevices/strings.json create mode 100644 tests/components/ptdevices/__init__.py create mode 100644 tests/components/ptdevices/conftest.py create mode 100644 tests/components/ptdevices/fixtures/ptdevices_level.json create mode 100644 tests/components/ptdevices/snapshots/test_sensor.ambr create mode 100644 tests/components/ptdevices/test_config_flow.py create mode 100644 tests/components/ptdevices/test_sensor.py diff --git a/.strict-typing b/.strict-typing index 43ddeb282dd7f..65d63a742580a 100644 --- a/.strict-typing +++ b/.strict-typing @@ -442,6 +442,7 @@ homeassistant.components.private_ble_device.* homeassistant.components.prometheus.* homeassistant.components.proximity.* homeassistant.components.prusalink.* +homeassistant.components.ptdevices.* homeassistant.components.pure_energie.* homeassistant.components.purpleair.* homeassistant.components.pushbullet.* diff --git a/CODEOWNERS b/CODEOWNERS index 715903bcffe0a..ee0d64341ec40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1378,6 +1378,8 @@ CLAUDE.md @home-assistant/core /tests/components/proxmoxve/ @Corbeno @erwindouna @CoMPaTech /homeassistant/components/ps4/ @ktnrg45 /tests/components/ps4/ @ktnrg45 +/homeassistant/components/ptdevices/ @ParemTech-Inc @frogman85978 +/tests/components/ptdevices/ @ParemTech-Inc @frogman85978 /homeassistant/components/pterodactyl/ @elmurato /tests/components/pterodactyl/ @elmurato /homeassistant/components/pure_energie/ @klaasnicolaas diff --git a/homeassistant/components/ptdevices/__init__.py b/homeassistant/components/ptdevices/__init__.py new file mode 100644 index 0000000000000..9a557749494e2 --- /dev/null +++ b/homeassistant/components/ptdevices/__init__.py @@ -0,0 +1,46 @@ +"""The PTDevices integration.""" + +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface + +from homeassistant.const import CONF_API_TOKEN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator + +_PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: PTDevicesConfigEntry +) -> bool: + """Set up PTDevices from a config entry.""" + auth_token: str = config_entry.data[CONF_API_TOKEN] + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=auth_token, + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + config_entry.runtime_data = coordinator = PTDevicesCoordinator( + hass, + config_entry, + ptdevices_interface, + ) + await coordinator.async_config_entry_first_refresh() + await hass.config_entries.async_forward_entry_setups(config_entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PTDevicesConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/ptdevices/config_flow.py b/homeassistant/components/ptdevices/config_flow.py new file mode 100644 index 0000000000000..505ed7053bd23 --- /dev/null +++ b/homeassistant/components/ptdevices/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for PTDevices integration.""" + +import logging +from typing import Any + +import aioptdevices +from aioptdevices.configuration import Configuration +from aioptdevices.interface import Interface +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DEFAULT_URL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +_CONF_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + } +) + + +async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> tuple[str, str]: + """Validate the user input allows us to connect. + + Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. + """ + + session = async_get_clientsession(hass) + ptdevices_interface = Interface( + Configuration( + auth_token=data[CONF_API_TOKEN], + device_id="*", # Retrieve data for all devices in account + url=DEFAULT_URL, + session=session, + ) + ) + + # Test Connection + try: + response = await ptdevices_interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise CannotConnect from err + + except aioptdevices.PTDevicesUnauthorizedError as err: + raise InvalidAuth from err + + body = response["body"] + + # Ensure the first device exists + first_device = next(iter(body.values()), None) + if first_device is None: + raise NoDevicesFound + + user_name = first_device.get("user_name") + user_id = first_device.get("user_id") + + title: str = str(user_name) + unique_id: str = str(user_id) + + # Return title to be used for hub name + return (title, unique_id) + + +class PTDevicesConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for PTDevices.""" + + VERSION = 1 + MINOR_VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + + errors: dict[str, str] = {} + + # Test connection when user data is available + if user_input is not None: + # Test connection + try: + title, unique_id = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_access_token" + except NoDevicesFound: + errors["base"] = "no_devices_found" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + # Connection Successful + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=title, data=user_input) + + # Show setup form + return self.async_show_form( + step_id="user", data_schema=_CONF_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class NoDevicesFound(HomeAssistantError): + """No devices were found in the account.""" diff --git a/homeassistant/components/ptdevices/const.py b/homeassistant/components/ptdevices/const.py new file mode 100644 index 0000000000000..829272fc27111 --- /dev/null +++ b/homeassistant/components/ptdevices/const.py @@ -0,0 +1,4 @@ +"""Constants for the PTDevices integration.""" + +DOMAIN = "ptdevices" +DEFAULT_URL = "https://api.ptdevices.com/token/v1" diff --git a/homeassistant/components/ptdevices/coordinator.py b/homeassistant/components/ptdevices/coordinator.py new file mode 100644 index 0000000000000..353918356f9bc --- /dev/null +++ b/homeassistant/components/ptdevices/coordinator.py @@ -0,0 +1,88 @@ +"""Coordinator for PTDevices integration.""" + +from datetime import timedelta +import logging +from typing import Final + +import aioptdevices +from aioptdevices.interface import Interface, PTDevicesResponseData + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.debounce import Debouncer +from homeassistant.helpers.update_coordinator import ( + REQUEST_REFRESH_DEFAULT_IMMEDIATE, + DataUpdateCoordinator, + UpdateFailed, +) + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +REFRESH_COOLDOWN: Final = 30 +UPDATE_INTERVAL = timedelta(seconds=60) + +type PTDevicesConfigEntry = ConfigEntry[PTDevicesCoordinator] + + +class PTDevicesCoordinator(DataUpdateCoordinator[PTDevicesResponseData]): + """Class for interacting with PTDevices get_data.""" + + config_entry: PTDevicesConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + ptdevices_interface: Interface, + ) -> None: + """Initialize the coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=UPDATE_INTERVAL, + request_refresh_debouncer=Debouncer( + hass, + _LOGGER, + immediate=REQUEST_REFRESH_DEFAULT_IMMEDIATE, + cooldown=REFRESH_COOLDOWN, + ), + ) + + self.interface = ptdevices_interface + + async def _async_update_data(self) -> PTDevicesResponseData: + try: + data = await self.interface.get_data() + except aioptdevices.PTDevicesRequestError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except aioptdevices.PTDevicesUnauthorizedError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_access_token", + translation_placeholders={"error": repr(err)}, + ) from err + + # Purge stale devices + device_reg = dr.async_get(self.hass) + identifiers = { + (DOMAIN, f"{device_data['user_id']}_{device_id}") + for device_id, device_data in data["body"].items() + } + for device in dr.async_entries_for_config_entry( + device_reg, self.config_entry.entry_id + ): + if not set(device.identifiers) & identifiers: + _LOGGER.debug("Removing stale device entry %s", device.name) + device_reg.async_update_device( + device.id, remove_config_entry_id=self.config_entry.entry_id + ) + + return data["body"] diff --git a/homeassistant/components/ptdevices/entity.py b/homeassistant/components/ptdevices/entity.py new file mode 100644 index 0000000000000..f8df42c330e3c --- /dev/null +++ b/homeassistant/components/ptdevices/entity.py @@ -0,0 +1,49 @@ +"""PTDevices integration.""" + +from typing import Any + +from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PTDevicesCoordinator + + +class PTDevicesEntity(CoordinatorEntity[PTDevicesCoordinator]): + """Defines a base PTDevices entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PTDevicesCoordinator, + sensor_key: str, + device_id: str, + ) -> None: + """Initialize.""" + super().__init__(coordinator=coordinator) + self._sensor_key = sensor_key + self._device_id = device_id + self._user_id = coordinator.data[self._device_id]["user_id"] + + self._attr_unique_id = f"{self._user_id}_{device_id}_{sensor_key}" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{self._user_id}_{self._device_id}")}, + connections={(CONNECTION_NETWORK_MAC, self._device_id)}, + configuration_url=f"https://www.ptdevices.com/device/level/{self.device['id']}", + manufacturer="ParemTech Inc.", + model=self.device["device_type"], + sw_version=str(self.device["version"]), + name=self.device["title"], + ) + + @property + def device(self) -> dict[str, Any]: + """Return the device data.""" + return self.coordinator.data[self._device_id] + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self._device_id in self.coordinator.data diff --git a/homeassistant/components/ptdevices/icons.json b/homeassistant/components/ptdevices/icons.json new file mode 100644 index 0000000000000..8c17cf0a8a8fc --- /dev/null +++ b/homeassistant/components/ptdevices/icons.json @@ -0,0 +1,30 @@ +{ + "entity": { + "sensor": { + "battery_voltage": { + "default": "mdi:battery" + }, + "depth_level": { + "default": "mdi:water" + }, + "percent_level": { + "default": "mdi:water-percent" + }, + "probe_temperature": { + "default": "mdi:thermometer" + }, + "status": { + "default": "mdi:information-outline" + }, + "tx_signal": { + "default": "mdi:wifi" + }, + "volume_level": { + "default": "mdi:water" + }, + "wifi_signal": { + "default": "mdi:wifi" + } + } + } +} diff --git a/homeassistant/components/ptdevices/manifest.json b/homeassistant/components/ptdevices/manifest.json new file mode 100644 index 0000000000000..149e67106181d --- /dev/null +++ b/homeassistant/components/ptdevices/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "ptdevices", + "name": "PTDevices", + "codeowners": ["@ParemTech-Inc", "@frogman85978"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ptdevices", + "integration_type": "hub", + "iot_class": "cloud_polling", + "loggers": ["aioptdevices"], + "quality_scale": "bronze", + "requirements": ["aioptdevices==2026.03.2"] +} diff --git a/homeassistant/components/ptdevices/quality_scale.yaml b/homeassistant/components/ptdevices/quality_scale.yaml new file mode 100644 index 0000000000000..5a6ae39af27ce --- /dev/null +++ b/homeassistant/components/ptdevices/quality_scale.yaml @@ -0,0 +1,75 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + This integration does not provide any actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + This integration does not provide any actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + Entities of this integration do not explicitly subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + This integration does not provide any actions. + config-entry-unloading: done + docs-configuration-parameters: + status: exempt + comment: | + This integration does not provide any additional options. + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: done + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: done + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/ptdevices/sensor.py b/homeassistant/components/ptdevices/sensor.py new file mode 100644 index 0000000000000..df9549ac2289a --- /dev/null +++ b/homeassistant/components/ptdevices/sensor.py @@ -0,0 +1,203 @@ +"""Sensors for PTDevices device.""" + +from collections.abc import Callable +from dataclasses import dataclass +from enum import StrEnum +from typing import cast + +from aioptdevices.interface import PTDevicesStatusStates + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import ( + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + EntityCategory, + UnitOfElectricPotential, + UnitOfLength, + UnitOfTemperature, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import PTDevicesConfigEntry, PTDevicesCoordinator +from .entity import PTDevicesEntity + +# Coordinator is used to centralize the data updates +PARALLEL_UPDATES = 0 + + +class PTDevicesSensors(StrEnum): + """Store keys for PTDevices sensors.""" + + LEVEL_PERCENT = "percent_level" + LEVEL_VOLUME = "volume_level" + LEVEL_DEPTH = "depth_level" + PROBE_TEMPERATURE = "probe_temperature" + DEVICE_STATUS = "status" + DEVICE_WIFI_STRENGTH = "wifi_signal" + DEVICE_BATTERY_VOLTAGE = "battery_voltage" + TX_SIGNAL_STRENGTH = "tx_signal" + + +@dataclass(kw_only=True, frozen=True) +class PTDevicesSensorEntityDescription(SensorEntityDescription): + """Description for PTDevices sensor entities.""" + + value_fn: Callable[[dict[str, str | int | float | None]], str | int | float | None] + + +SENSOR_DESCRIPTIONS: tuple[PTDevicesSensorEntityDescription, ...] = ( + # Percent of water in the tank + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_PERCENT, + translation_key=PTDevicesSensors.LEVEL_PERCENT, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_PERCENT)), + ), + # Volume of water in the tank (Liters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_VOLUME, + translation_key=PTDevicesSensors.LEVEL_VOLUME, + native_unit_of_measurement=UnitOfVolume.LITERS, + device_class=SensorDeviceClass.VOLUME_STORAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_VOLUME)), + ), + # Depth of water in the tank (Meters) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.LEVEL_DEPTH, + translation_key=PTDevicesSensors.LEVEL_DEPTH, + native_unit_of_measurement=UnitOfLength.METERS, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.LEVEL_DEPTH)), + suggested_display_precision=3, + ), + # Temperature measured by external temperature probe (Celsius) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.PROBE_TEMPERATURE, + translation_key=PTDevicesSensors.PROBE_TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast(float, data.get(PTDevicesSensors.PROBE_TEMPERATURE)), + ), + # Status of the device + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_STATUS, + translation_key=PTDevicesSensors.DEVICE_STATUS, + device_class=SensorDeviceClass.ENUM, + options=[ + member.value + for member in PTDevicesStatusStates + if member.value != "unknown" + ], + value_fn=lambda data: ( + cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) + if cast(str, data.get(PTDevicesSensors.DEVICE_STATUS)) != "unknown" + else None + ), + ), + # Wifi signal strength (%) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + translation_key=PTDevicesSensors.DEVICE_WIFI_STRENGTH, + native_unit_of_measurement=PERCENTAGE, + entity_registry_enabled_default=False, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + int, data.get(PTDevicesSensors.DEVICE_WIFI_STRENGTH) + ), + ), + # LoRa signal strength (dBm) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + translation_key=PTDevicesSensors.TX_SIGNAL_STRENGTH, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + entity_registry_enabled_default=False, + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.TX_SIGNAL_STRENGTH) + ), + ), + # Battery voltage (Volts) + PTDevicesSensorEntityDescription( + key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + translation_key=PTDevicesSensors.DEVICE_BATTERY_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + entity_registry_enabled_default=False, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda data: cast( + float, data.get(PTDevicesSensors.DEVICE_BATTERY_VOLTAGE) + ), + suggested_display_precision=2, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: PTDevicesConfigEntry, + async_add_entity: AddConfigEntryEntitiesCallback, +) -> None: + """Set up PTDevices sensors from config entries.""" + coordinator = config_entry.runtime_data + + known_sensors: set[tuple[str, str]] = set() + + def _check_device() -> None: + for device_id in sorted(coordinator.data): + device = coordinator.data[device_id] + new_sensors = [ + sensor + for sensor in SENSOR_DESCRIPTIONS + if sensor.key in device and (device_id, sensor.key) not in known_sensors + ] + if not new_sensors: + continue + known_sensors.update((device_id, sensor.key) for sensor in new_sensors) + async_add_entity( + PTDevicesSensorEntity(config_entry.runtime_data, sensor, device_id) + for sensor in new_sensors + ) + + _check_device() + config_entry.async_on_unload(coordinator.async_add_listener(_check_device)) + + +class PTDevicesSensorEntity(PTDevicesEntity, SensorEntity): + """Sensor entity for PTDevices Integration.""" + + entity_description: PTDevicesSensorEntityDescription + + def __init__( + self, + coordinator: PTDevicesCoordinator, + description: PTDevicesSensorEntityDescription, + device_id: str, + ) -> None: + """Initialize sensor.""" + super().__init__( + coordinator, + description.key, + device_id, + ) + + self.entity_description = description + + @property + def native_value(self) -> float | int | str | None: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.device) diff --git a/homeassistant/components/ptdevices/strings.json b/homeassistant/components/ptdevices/strings.json new file mode 100644 index 0000000000000..4b0fd67ac6695 --- /dev/null +++ b/homeassistant/components/ptdevices/strings.json @@ -0,0 +1,69 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_access_token": "[%key:common::config_flow::error::invalid_access_token%]", + "no_devices_found": "No devices are registered to your PTDevices account.", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "api_token": "[%key:common::config_flow::data::api_token%]" + }, + "data_description": { + "api_token": "The API token for your PTDevices account." + }, + "description": "Enter the API token for your PTDevices account" + } + } + }, + "entity": { + "sensor": { + "battery_voltage": { + "name": "Battery voltage" + }, + "depth_level": { + "name": "Level depth" + }, + "percent_level": { + "name": "Level percent" + }, + "probe_temperature": { + "name": "Probe temperature" + }, + "status": { + "name": "Status", + "state": { + "not_connected": "Not connected", + "not_connected_yet": "Not connected yet", + "power_internet_out_or_receiver_not_working": "Power or internet out or receiver not working", + "press_transmitter_connect_button": "Press transmitter connect button", + "transmitter_not_reporting": "Transmitter not reporting", + "working": "Working" + } + }, + "tx_signal": { + "name": "LoRa signal strength" + }, + "volume_level": { + "name": "Level volume" + }, + "wifi_signal": { + "name": "Wi-Fi signal strength" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "[%key:common::config_flow::error::cannot_connect%]" + }, + "invalid_access_token": { + "message": "[%key:common::config_flow::error::invalid_access_token%]" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d981856b0e492..2478179f02373 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -579,6 +579,7 @@ "proxmoxve", "prusalink", "ps4", + "ptdevices", "pterodactyl", "pure_energie", "purpleair", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 9c76b93ae4669..acd878735efb5 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5501,6 +5501,12 @@ "integration_type": "virtual", "supported_by": "opower" }, + "ptdevices": { + "name": "PTDevices", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "pterodactyl": { "name": "Pterodactyl", "integration_type": "service", diff --git a/mypy.ini b/mypy.ini index 9f64f3e56506a..dd2d50072f1c8 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4175,6 +4175,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.ptdevices.*] +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.pure_energie.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 17a3175ba4de0..650e7db072351 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -359,6 +359,9 @@ aiopegelonline==0.1.1 # homeassistant.components.opnsense aiopnsense==1.0.8 +# homeassistant.components.ptdevices +aioptdevices==2026.03.2 + # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30c238e51eab2..15ccc2bcd3ba1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -344,6 +344,9 @@ aiopegelonline==0.1.1 # homeassistant.components.opnsense aiopnsense==1.0.8 +# homeassistant.components.ptdevices +aioptdevices==2026.03.2 + # homeassistant.components.acmeda aiopulse==0.4.6 diff --git a/tests/components/ptdevices/__init__.py b/tests/components/ptdevices/__init__.py new file mode 100644 index 0000000000000..25780fff64d9d --- /dev/null +++ b/tests/components/ptdevices/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the PTDevices component.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Method for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/ptdevices/conftest.py b/tests/components/ptdevices/conftest.py new file mode 100644 index 0000000000000..c8c014eeeadf7 --- /dev/null +++ b/tests/components/ptdevices/conftest.py @@ -0,0 +1,68 @@ +"""Common fixtures for the PTDevices tests.""" + +from collections.abc import Generator +from typing import cast +from unittest.mock import AsyncMock, patch + +from aioptdevices.interface import PTDevicesResponse, PTDevicesResponseData +import pytest + +from homeassistant.components.ptdevices.const import DOMAIN +from homeassistant.const import CONF_API_TOKEN + +from tests.common import MockConfigEntry, load_json_object_fixture + + +@pytest.fixture +def mock_ptdevices_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.ptdevices.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_ptdevices_level() -> PTDevicesResponse: + """Mock a PTLevel device.""" + data = load_json_object_fixture("ptdevices_level.json", DOMAIN) + return PTDevicesResponse( + code=200, + body=cast(PTDevicesResponseData, data), + ) + + +@pytest.fixture +def mock_ptdevices_interface( + mock_ptdevices_level: PTDevicesResponse, +) -> Generator[AsyncMock]: + """Mock a PTDevices Interface.""" + with ( + patch( + "homeassistant.components.ptdevices.Interface", + autospec=True, + ) as mock_interface, + patch( + "homeassistant.components.ptdevices.config_flow.Interface", + new=mock_interface, + ), + ): + interface = mock_interface.return_value + interface.get_data.return_value = mock_ptdevices_level + + yield interface + + +@pytest.fixture +def mock_ptdevices_config_entry() -> MockConfigEntry: + """Return a mocked ptdevice configuration entry.""" + return MockConfigEntry( + version=1, + domain=DOMAIN, + title="User Name", + data={ + CONF_API_TOKEN: "test-api-token", + }, + unique_id="1234", + ) diff --git a/tests/components/ptdevices/fixtures/ptdevices_level.json b/tests/components/ptdevices/fixtures/ptdevices_level.json new file mode 100644 index 0000000000000..c69e7049696d1 --- /dev/null +++ b/tests/components/ptdevices/fixtures/ptdevices_level.json @@ -0,0 +1,86 @@ +{ + "C0FFEEC0FFEE": { + "id": 1, + "device_id": "C0FFEEC0FFEE", + "share_id": "someID", + "user_id": 1234, + "user_name": "User Name", + "user_email": "userEmail@email.com", + "device_type": "level", + "local_ip": "192.168.1.100", + "title": "Home", + "version": 208, + "lat": 40.62669, + "lng": -82.031121, + "address": "1234 Test Road, City, Country", + "supplier_code": null, + "status_number": 2, + "status": "working", + "delivery_notes": null, + "units": "Metric", + "reported": "Dec 17th, 9:37 AM", + "tx_reported": "Dec 17th, 8:15 AM", + "last_updated_on": "1 hour ago", + "wifi_signal": 100, + "tx_signal": -76.0, + "percent_level": 50, + "battery_voltage": 5.69, + "battery_status": "good", + "battery_status_number": 1, + "volume_level": 2387.837753, + "volume_level_oz": 80742.4, + "max_volume": 1269, + "max_volume_oz": 162432, + "enclosure_temperature": -0.3, + "depth": 6, + "power_x": 30, + "power_y": 7, + "power_z": 12, + "shape": "vertical cylinder", + "diameter": 6, + "width": null, + "length": null, + "temperature_units": "C", + "depth_level": 0.909066 + }, + "C0FFEFC0FFEF": { + "id": 2, + "device_id": "C0FFEFC0FFEF", + "share_id": "someID", + "created": "Jan 1st 1970, 0:00 AM", + "user_id": 1234, + "user_name": "User Name", + "user_email": "userEmail@email.com", + "device_type": "level", + "local_ip": "192.168.1.101", + "title": "Garden rain barrel", + "version": "407", + "lat": 43.147985, + "lng": -29.17074, + "address": "1234 Test Road, City, Country", + "supplier_code": null, + "status_number": 2, + "status": "working", + "delivery_notes": null, + "units": "US Imperial", + "reported": "Dec 17th, 9:38 AM", + "tx_reported": "Dec 17th, 9:38 AM", + "last_updated_on": "13 seconds ago", + "wifi_signal": 42, + "tx_signal": 0.0, + "percent_level": 141, + "volume_level": 4.542494, + "volume_level_oz": 153.6, + "max_volume": 1.2, + "max_volume_oz": 153.6, + "enclosure_temperature": 37.3, + "probe_temperature": 18.9, + "depth": 0.85, + "shape": "vertical cylinder", + "diameter": 0.5, + "width": null, + "length": null, + "temperature_units": "C", + "depth_level": 0.363474 + } +} diff --git a/tests/components/ptdevices/snapshots/test_sensor.ambr b/tests/components/ptdevices/snapshots/test_sensor.ambr new file mode 100644 index 0000000000000..ff9f574801e6f --- /dev/null +++ b/tests/components/ptdevices/snapshots/test_sensor.ambr @@ -0,0 +1,811 @@ +# serializer version: 1 +# name: test_all_entities[sensor.garden_rain_barrel_level_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_level_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level depth', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level depth', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_depth_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Garden rain barrel Level depth', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.363474', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_level_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level percent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level percent', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_percent_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden rain barrel Level percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '141', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_level_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level volume', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_volume_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_level_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Garden rain barrel Level volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_level_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '4.542494', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_lora_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garden_rain_barrel_lora_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LoRa signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LoRa signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_tx_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_lora_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Garden rain barrel LoRa signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_lora_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_probe_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_probe_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Probe temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Probe temperature', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_probe_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_probe_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Garden rain barrel Probe temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_probe_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '18.9', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.garden_rain_barrel_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Garden rain barrel Status', + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'working', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_wi_fi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.garden_rain_barrel_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEFC0FFEF_wifi_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.garden_rain_barrel_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Garden rain barrel Wi-Fi signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.garden_rain_barrel_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '42', + }) +# --- +# name: test_all_entities[sensor.home_battery_voltage-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_battery_voltage', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Battery voltage', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery voltage', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_battery_voltage', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_battery_voltage-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'Home Battery voltage', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_battery_voltage', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '5.69', + }) +# --- +# name: test_all_entities[sensor.home_level_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_level_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level depth', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 3, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level depth', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_depth_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_level_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Home Level depth', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_level_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.909066', + }) +# --- +# name: test_all_entities[sensor.home_level_percent-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_level_percent', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level percent', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Level percent', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_percent_level', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.home_level_percent-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Level percent', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_level_percent', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- +# name: test_all_entities[sensor.home_level_volume-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_level_volume', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Level volume', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Level volume', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_volume_level', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.home_level_volume-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_storage', + 'friendly_name': 'Home Level volume', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.home_level_volume', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '2387.837753', + }) +# --- +# name: test_all_entities[sensor.home_lora_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_lora_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'LoRa signal strength', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'LoRa signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_tx_signal', + 'unit_of_measurement': 'dBm', + }) +# --- +# name: test_all_entities[sensor.home_lora_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'signal_strength', + 'friendly_name': 'Home LoRa signal strength', + 'state_class': , + 'unit_of_measurement': 'dBm', + }), + 'context': , + 'entity_id': 'sensor.home_lora_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '-76.0', + }) +# --- +# name: test_all_entities[sensor.home_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.home_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Status', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.home_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Home Status', + 'options': list([ + 'working', + 'not_connected_yet', + 'not_connected', + 'transmitter_not_reporting', + 'press_transmitter_connect_button', + 'power_internet_out_or_receiver_not_working', + ]), + }), + 'context': , + 'entity_id': 'sensor.home_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'working', + }) +# --- +# name: test_all_entities[sensor.home_wi_fi_signal_strength-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.home_wi_fi_signal_strength', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Wi-Fi signal strength', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Wi-Fi signal strength', + 'platform': 'ptdevices', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': '1234_C0FFEEC0FFEE_wifi_signal', + 'unit_of_measurement': '%', + }) +# --- +# name: test_all_entities[sensor.home_wi_fi_signal_strength-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Home Wi-Fi signal strength', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.home_wi_fi_signal_strength', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100', + }) +# --- diff --git a/tests/components/ptdevices/test_config_flow.py b/tests/components/ptdevices/test_config_flow.py new file mode 100644 index 0000000000000..7ef17cb956338 --- /dev/null +++ b/tests/components/ptdevices/test_config_flow.py @@ -0,0 +1,151 @@ +"""Test the PTDevices config flow.""" + +from unittest.mock import AsyncMock + +from aioptdevices import PTDevicesRequestError, PTDevicesUnauthorizedError +from aioptdevices.interface import PTDevicesResponse +import pytest + +from homeassistant.components.ptdevices.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_flow_success( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, +) -> None: + """Test a successful creation of config entries via user configuration.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "User Name" + assert result["result"].unique_id == "1234" + assert result["data"] == { + CONF_API_TOKEN: "test-api-token", + } + + assert len(mock_ptdevices_interface.mock_calls) == 1 + + +async def test_flow_duplicate_device( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, +) -> None: + """Test a duplicate config flow.""" + mock_ptdevices_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (PTDevicesUnauthorizedError, "invalid_access_token"), + (PTDevicesRequestError, "cannot_connect"), + (Exception, "unknown"), + ], +) +async def test_flow_errors( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test flow errors.""" + mock_ptdevices_interface.get_data.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": error} + + mock_ptdevices_interface.get_data.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_flow_no_devices( + hass: HomeAssistant, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_setup_entry: AsyncMock, + mock_ptdevices_level: PTDevicesResponse, +) -> None: + """Test A flow with no devices in the account.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + # No devices + mock_ptdevices_interface.get_data.return_value = PTDevicesResponse( + code=200, + body={}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_devices_found"} + + # Reset the mock to the default return value + mock_ptdevices_interface.get_data.return_value = mock_ptdevices_level + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_API_TOKEN: "test-api-token"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY diff --git a/tests/components/ptdevices/test_sensor.py b/tests/components/ptdevices/test_sensor.py new file mode 100644 index 0000000000000..494fc632e5558 --- /dev/null +++ b/tests/components/ptdevices/test_sensor.py @@ -0,0 +1,31 @@ +"""Test for PTDevices sensors.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_ptdevices_interface: AsyncMock, + mock_ptdevices_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch("homeassistant.components.ptdevices._PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_ptdevices_config_entry) + + await snapshot_platform( + hass, entity_registry, snapshot, mock_ptdevices_config_entry.entry_id + ) From 1b4a7d55c0f769052a39d921d0585e378fbad313 Mon Sep 17 00:00:00 2001 From: shbatm Date: Mon, 4 May 2026 15:29:12 -0500 Subject: [PATCH 20/22] Add precipitation device class to WeatherFlow Cloud accumulation sensors (#169638) Co-authored-by: Claude Opus 4.7 --- .../components/weatherflow_cloud/sensor.py | 16 ++++++--- .../snapshots/test_sensor.ambr | 35 +++++++++++-------- 2 files changed, 31 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/weatherflow_cloud/sensor.py b/homeassistant/components/weatherflow_cloud/sensor.py index 0ac949dc48202..be4fd2ee9a224 100644 --- a/homeassistant/components/weatherflow_cloud/sensor.py +++ b/homeassistant/components/weatherflow_cloud/sensor.py @@ -21,6 +21,7 @@ from homeassistant.const import ( EntityCategory, UnitOfLength, + UnitOfPrecipitationDepth, UnitOfPressure, UnitOfSpeed, UnitOfTemperature, @@ -235,42 +236,47 @@ class WeatherFlowCloudSensorEntityDescriptionWebsocketObservation( WeatherFlowCloudSensorEntityDescription( key="precip_accum_last_1hr", translation_key="precip_accum_last_1hr", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_last_1hr, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_day", translation_key="precip_accum_local_day", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_day, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_day_final", translation_key="precip_accum_local_day_final", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_day_final, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_yesterday", translation_key="precip_accum_local_yesterday", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_yesterday, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_accum_local_yesterday_final", translation_key="precip_accum_local_yesterday_final", + device_class=SensorDeviceClass.PRECIPITATION, state_class=SensorStateClass.MEASUREMENT, suggested_display_precision=1, value_fn=lambda data: data.precip_accum_local_yesterday_final, - native_unit_of_measurement=UnitOfLength.MILLIMETERS, + native_unit_of_measurement=UnitOfPrecipitationDepth.MILLIMETERS, ), WeatherFlowCloudSensorEntityDescription( key="precip_analysis_type_yesterday", diff --git a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr index 47944adb3f723..4a430edb5abbf 100644 --- a/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr +++ b/tests/components/weatherflow_cloud/snapshots/test_sensor.ambr @@ -595,7 +595,7 @@ 'suggested_display_precision': 1, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Nearcast precipitation today', 'platform': 'weatherflow_cloud', @@ -604,16 +604,17 @@ 'supported_features': 0, 'translation_key': 'precip_accum_local_day_final', 'unique_id': '24432_precip_accum_local_day_final', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.my_home_station_nearcast_precipitation_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'precipitation', 'friendly_name': 'My Home Station Nearcast precipitation today', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_home_station_nearcast_precipitation_today', @@ -653,7 +654,7 @@ 'suggested_display_precision': 1, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Nearcast precipitation yesterday', 'platform': 'weatherflow_cloud', @@ -662,16 +663,17 @@ 'supported_features': 0, 'translation_key': 'precip_accum_local_yesterday_final', 'unique_id': '24432_precip_accum_local_yesterday_final', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.my_home_station_nearcast_precipitation_yesterday-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'precipitation', 'friendly_name': 'My Home Station Nearcast precipitation yesterday', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_home_station_nearcast_precipitation_yesterday', @@ -827,7 +829,7 @@ 'suggested_display_precision': 1, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation today', 'platform': 'weatherflow_cloud', @@ -836,16 +838,17 @@ 'supported_features': 0, 'translation_key': 'precip_accum_local_day', 'unique_id': '24432_precip_accum_local_day', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.my_home_station_precipitation_today-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'precipitation', 'friendly_name': 'My Home Station Precipitation today', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_home_station_precipitation_today', @@ -955,7 +958,7 @@ 'suggested_display_precision': 1, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Precipitation yesterday', 'platform': 'weatherflow_cloud', @@ -964,16 +967,17 @@ 'supported_features': 0, 'translation_key': 'precip_accum_local_yesterday', 'unique_id': '24432_precip_accum_local_yesterday', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.my_home_station_precipitation_yesterday-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'precipitation', 'friendly_name': 'My Home Station Precipitation yesterday', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_home_station_precipitation_yesterday', @@ -1137,7 +1141,7 @@ 'suggested_display_precision': 1, }), }), - 'original_device_class': None, + 'original_device_class': , 'original_icon': None, 'original_name': 'Rain last hour', 'platform': 'weatherflow_cloud', @@ -1146,16 +1150,17 @@ 'supported_features': 0, 'translation_key': 'precip_accum_last_1hr', 'unique_id': '24432_precip_accum_last_1hr', - 'unit_of_measurement': , + 'unit_of_measurement': , }) # --- # name: test_all_entities[sensor.my_home_station_rain_last_hour-state] StateSnapshot({ 'attributes': ReadOnlyDict({ 'attribution': 'Weather data delivered by WeatherFlow/Tempest API', + 'device_class': 'precipitation', 'friendly_name': 'My Home Station Rain last hour', 'state_class': , - 'unit_of_measurement': , + 'unit_of_measurement': , }), 'context': , 'entity_id': 'sensor.my_home_station_rain_last_hour', From 63dfc973462c18e235c3725f4434014aeca0db86 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Mon, 4 May 2026 14:51:17 -0600 Subject: [PATCH 21/22] Limit power status binary sensor to non-LR5 devices (#169659) --- .../components/litterrobot/binary_sensor.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/litterrobot/binary_sensor.py b/homeassistant/components/litterrobot/binary_sensor.py index 00cd43641f5ad..6cc68a7d87a2a 100644 --- a/homeassistant/components/litterrobot/binary_sensor.py +++ b/homeassistant/components/litterrobot/binary_sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Generic -from pylitterbot import LitterRobot, LitterRobot4, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot from homeassistant.components.binary_sensor import ( BinarySensorDeviceClass, @@ -30,8 +30,11 @@ class RobotBinarySensorEntityDescription( is_on_fn: Callable[[_WhiskerEntityT], bool] -BINARY_SENSOR_MAP: dict[type[Robot], tuple[RobotBinarySensorEntityDescription, ...]] = { - LitterRobot: ( # type: ignore[type-abstract] # only used for isinstance check +BINARY_SENSOR_MAP: dict[ + type[Robot] | tuple[type[Robot], ...], + tuple[RobotBinarySensorEntityDescription, ...], +] = { + LitterRobot: ( RobotBinarySensorEntityDescription[LitterRobot]( key="sleeping", translation_key="sleeping", @@ -56,8 +59,8 @@ class RobotBinarySensorEntityDescription( is_on_fn=lambda robot: not robot.is_hopper_removed, ), ), - Robot: ( # type: ignore[type-abstract] # only used for isinstance check - RobotBinarySensorEntityDescription[Robot]( + (FeederRobot, LitterRobot3, LitterRobot4): ( + RobotBinarySensorEntityDescription[FeederRobot | LitterRobot3 | LitterRobot4]( key="power_status", translation_key="power_status", device_class=BinarySensorDeviceClass.PLUG, From 57e66baf53569d3ebfee0265fedade89140a47cd Mon Sep 17 00:00:00 2001 From: "A. Gideonse" Date: Mon, 4 May 2026 23:05:11 +0200 Subject: [PATCH 22/22] Update Indevolt integration quality scale to silver (#167843) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/indevolt/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/indevolt/manifest.json b/homeassistant/components/indevolt/manifest.json index 5a60be83d573c..442a641dfeb18 100644 --- a/homeassistant/components/indevolt/manifest.json +++ b/homeassistant/components/indevolt/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/indevolt", "integration_type": "device", "iot_class": "local_polling", - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["indevolt-api==1.7.1"] }