From 6aafa666d611d212025b8a75970a35c230247555 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 16 Sep 2025 17:29:04 +0200 Subject: [PATCH 01/17] Add calendar to Workday (#150596) --- homeassistant/components/workday/calendar.py | 104 ++++++++++++++++++ homeassistant/components/workday/const.py | 2 +- homeassistant/components/workday/strings.json | 5 + tests/components/workday/test_calendar.py | 83 ++++++++++++++ 4 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/workday/calendar.py create mode 100644 tests/components/workday/test_calendar.py diff --git a/homeassistant/components/workday/calendar.py b/homeassistant/components/workday/calendar.py new file mode 100644 index 00000000000000..b6c7893b142d17 --- /dev/null +++ b/homeassistant/components/workday/calendar.py @@ -0,0 +1,104 @@ +"""Workday Calendar.""" + +from __future__ import annotations + +from datetime import datetime, timedelta + +from holidays import HolidayBase + +from homeassistant.components.calendar import CalendarEntity, CalendarEvent +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import WorkdayConfigEntry +from .const import CONF_EXCLUDES, CONF_OFFSET, CONF_WORKDAYS +from .entity import BaseWorkdayEntity + +CALENDAR_DAYS_AHEAD = 365 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: WorkdayConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Holiday Calendar config entry.""" + days_offset: int = int(entry.options[CONF_OFFSET]) + excludes: list[str] = entry.options[CONF_EXCLUDES] + sensor_name: str = entry.options[CONF_NAME] + workdays: list[str] = entry.options[CONF_WORKDAYS] + obj_holidays = entry.runtime_data + + async_add_entities( + [ + WorkdayCalendarEntity( + obj_holidays, + workdays, + excludes, + days_offset, + sensor_name, + entry.entry_id, + ) + ], + ) + + +class WorkdayCalendarEntity(BaseWorkdayEntity, CalendarEntity): + """Representation of a Workday Calendar.""" + + def __init__( + self, + obj_holidays: HolidayBase, + workdays: list[str], + excludes: list[str], + days_offset: int, + name: str, + entry_id: str, + ) -> None: + """Initialize WorkdayCalendarEntity.""" + super().__init__( + obj_holidays, + workdays, + excludes, + days_offset, + name, + entry_id, + ) + self._attr_unique_id = entry_id + self._attr_event = None + self.event_list: list[CalendarEvent] = [] + self._name = name + + def update_data(self, now: datetime) -> None: + """Update data.""" + event_list = [] + for i in range(CALENDAR_DAYS_AHEAD): + future_date = now.date() + timedelta(days=i) + if self.date_is_workday(future_date): + event = CalendarEvent( + summary=self._name, + start=future_date, + end=future_date, + ) + event_list.append(event) + self.event_list = event_list + + @property + def event(self) -> CalendarEvent | None: + """Return the next upcoming event.""" + return ( + sorted(self.event_list, key=lambda e: e.start)[0] + if self.event_list + else None + ) + + async def async_get_events( + self, hass: HomeAssistant, start_date: datetime, end_date: datetime + ) -> list[CalendarEvent]: + """Get all events in a specific time frame.""" + return [ + workday + for workday in self.event_list + if start_date.date() <= workday.start <= end_date.date() + ] diff --git a/homeassistant/components/workday/const.py b/homeassistant/components/workday/const.py index 76580ae642f82d..e8a6656d9e2c60 100644 --- a/homeassistant/components/workday/const.py +++ b/homeassistant/components/workday/const.py @@ -11,7 +11,7 @@ ALLOWED_DAYS = [*WEEKDAYS, "holiday"] DOMAIN = "workday" -PLATFORMS = [Platform.BINARY_SENSOR] +PLATFORMS = [Platform.BINARY_SENSOR, Platform.CALENDAR] CONF_PROVINCE = "province" CONF_WORKDAYS = "workdays" diff --git a/homeassistant/components/workday/strings.json b/homeassistant/components/workday/strings.json index feedc52331ba68..e78ece25c21bcd 100644 --- a/homeassistant/components/workday/strings.json +++ b/homeassistant/components/workday/strings.json @@ -212,6 +212,11 @@ } } } + }, + "calendar": { + "workday": { + "name": "[%key:component::calendar::title%]" + } } }, "services": { diff --git a/tests/components/workday/test_calendar.py b/tests/components/workday/test_calendar.py new file mode 100644 index 00000000000000..5e5417362a34fb --- /dev/null +++ b/tests/components/workday/test_calendar.py @@ -0,0 +1,83 @@ +"""Tests for calendar platform of Workday integration.""" + +from datetime import datetime, timedelta + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.components.calendar import ( + DOMAIN as CALENDAR_DOMAIN, + EVENT_END_DATETIME, + EVENT_START_DATETIME, + EVENT_SUMMARY, + SERVICE_GET_EVENTS, +) +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import HomeAssistant +from homeassistant.util import dt as dt_util + +from . import TEST_CONFIG_WITH_PROVINCE, init_integration + +from tests.common import async_fire_time_changed + +ATTR_END = "end" +ATTR_START = "start" + + +@pytest.mark.parametrize( + "time_zone", ["Asia/Tokyo", "Europe/Berlin", "America/Chicago", "US/Hawaii"] +) +async def test_holiday_calendar_entity( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + time_zone: str, +) -> None: + """Test HolidayCalendarEntity functionality.""" + await hass.config.async_set_time_zone(time_zone) + zone = await dt_util.async_get_time_zone(time_zone) + freezer.move_to(datetime(2023, 1, 1, 0, 1, 1, tzinfo=zone)) # New Years Day + await init_integration(hass, TEST_CONFIG_WITH_PROVINCE) + + response = await hass.services.async_call( + CALENDAR_DOMAIN, + SERVICE_GET_EVENTS, + { + ATTR_ENTITY_ID: "calendar.workday_sensor_calendar", + EVENT_START_DATETIME: dt_util.now(), + EVENT_END_DATETIME: dt_util.now() + timedelta(days=10, hours=1), + }, + blocking=True, + return_response=True, + ) + assert { + ATTR_END: "2023-01-02", + ATTR_START: "2023-01-01", + EVENT_SUMMARY: "Workday Sensor", + } not in response["calendar.workday_sensor_calendar"]["events"] + assert { + ATTR_END: "2023-01-04", + ATTR_START: "2023-01-03", + EVENT_SUMMARY: "Workday Sensor", + } in response["calendar.workday_sensor_calendar"]["events"] + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "off" + + freezer.move_to( + datetime(2023, 1, 2, 0, 1, 1, tzinfo=zone) + ) # Day after New Years Day + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "on" + + freezer.move_to(datetime(2023, 1, 7, 0, 1, 1, tzinfo=zone)) # Workday + async_fire_time_changed(hass) + await hass.async_block_till_done() + + state = hass.states.get("calendar.workday_sensor_calendar") + assert state is not None + assert state.state == "off" From eb4a873c43be52a9e6f1b721eccb51cae4b920a4 Mon Sep 17 00:00:00 2001 From: Alessandro Manighetti <76836856+xtimmy86x@users.noreply.github.com> Date: Tue, 16 Sep 2025 18:02:22 +0200 Subject: [PATCH 02/17] Add m/min of speed sensors (#146441) --- homeassistant/const.py | 1 + homeassistant/util/unit_conversion.py | 2 ++ homeassistant/util/unit_system.py | 1 + tests/components/sensor/test_websocket_api.py | 1 + tests/util/test_unit_conversion.py | 14 ++++++++++++++ tests/util/test_unit_system.py | 1 + 6 files changed, 20 insertions(+) diff --git a/homeassistant/const.py b/homeassistant/const.py index 3c9de2af87cc53..3934b810db5794 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -898,6 +898,7 @@ class UnitOfSpeed(StrEnum): BEAUFORT = "Beaufort" FEET_PER_SECOND = "ft/s" INCHES_PER_SECOND = "in/s" + METERS_PER_MINUTE = "m/min" METERS_PER_SECOND = "m/s" KILOMETERS_PER_HOUR = "km/h" KNOTS = "kn" diff --git a/homeassistant/util/unit_conversion.py b/homeassistant/util/unit_conversion.py index 493de266080f58..f969a613a4786f 100644 --- a/homeassistant/util/unit_conversion.py +++ b/homeassistant/util/unit_conversion.py @@ -497,6 +497,7 @@ class SpeedConverter(BaseUnitConverter): UnitOfSpeed.INCHES_PER_SECOND: 1 / _IN_TO_M, UnitOfSpeed.KILOMETERS_PER_HOUR: _HRS_TO_SECS / _KM_TO_M, UnitOfSpeed.KNOTS: _HRS_TO_SECS / _NAUTICAL_MILE_TO_M, + UnitOfSpeed.METERS_PER_MINUTE: _MIN_TO_SEC, UnitOfSpeed.METERS_PER_SECOND: 1, UnitOfSpeed.MILLIMETERS_PER_SECOND: 1 / _MM_TO_M, UnitOfSpeed.MILES_PER_HOUR: _HRS_TO_SECS / _MILE_TO_M, @@ -511,6 +512,7 @@ class SpeedConverter(BaseUnitConverter): UnitOfSpeed.FEET_PER_SECOND, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_MINUTE, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILES_PER_HOUR, UnitOfSpeed.MILLIMETERS_PER_SECOND, diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index d86beb8b7e7cca..3268520e3f66e3 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -382,6 +382,7 @@ def _deprecated_unit_system(value: str) -> str: ("pressure", UnitOfPressure.MMHG): UnitOfPressure.INHG, ("pressure", UnitOfPressure.INH2O): UnitOfPressure.PSI, # Convert non-USCS speeds, except knots, to mph + ("speed", UnitOfSpeed.METERS_PER_MINUTE): UnitOfSpeed.INCHES_PER_SECOND, ("speed", UnitOfSpeed.METERS_PER_SECOND): UnitOfSpeed.MILES_PER_HOUR, ("speed", UnitOfSpeed.MILLIMETERS_PER_SECOND): UnitOfSpeed.INCHES_PER_SECOND, ("speed", UnitOfSpeed.KILOMETERS_PER_HOUR): UnitOfSpeed.MILES_PER_HOUR, diff --git a/tests/components/sensor/test_websocket_api.py b/tests/components/sensor/test_websocket_api.py index b1dafa04c94184..f0bb8f6c71fd83 100644 --- a/tests/components/sensor/test_websocket_api.py +++ b/tests/components/sensor/test_websocket_api.py @@ -39,6 +39,7 @@ async def test_device_class_units( "in/s", "km/h", "kn", + "m/min", "m/s", "mm/d", "mm/h", diff --git a/tests/util/test_unit_conversion.py b/tests/util/test_unit_conversion.py index 2938db4732ec2b..d9377779b68e83 100644 --- a/tests/util/test_unit_conversion.py +++ b/tests/util/test_unit_conversion.py @@ -751,6 +751,20 @@ (5, UnitOfSpeed.KILOMETERS_PER_HOUR, 3.106856, UnitOfSpeed.MILES_PER_HOUR), # 5 mi/h * 1.609 km/mi = 8.04672 km/h (5, UnitOfSpeed.MILES_PER_HOUR, 8.04672, UnitOfSpeed.KILOMETERS_PER_HOUR), + # 300 m/min / 60 s/min = 5 m/s + ( + 300, + UnitOfSpeed.METERS_PER_MINUTE, + 5, + UnitOfSpeed.METERS_PER_SECOND, + ), + # 5 m/s * 60 s/min = 300 m/min + ( + 5, + UnitOfSpeed.METERS_PER_SECOND, + 300, + UnitOfSpeed.METERS_PER_MINUTE, + ), # 5 in/day * 25.4 mm/in = 127 mm/day ( 5, diff --git a/tests/util/test_unit_system.py b/tests/util/test_unit_system.py index e8da55358a3b94..54e9d4080e3ec0 100644 --- a/tests/util/test_unit_system.py +++ b/tests/util/test_unit_system.py @@ -614,6 +614,7 @@ def test_get_metric_converted_unit_( UnitOfSpeed.BEAUFORT, UnitOfSpeed.KILOMETERS_PER_HOUR, UnitOfSpeed.KNOTS, + UnitOfSpeed.METERS_PER_MINUTE, UnitOfSpeed.METERS_PER_SECOND, UnitOfSpeed.MILLIMETERS_PER_SECOND, UnitOfVolumetricFlux.MILLIMETERS_PER_DAY, From 6b8c1805091695d25740c3e71ff45dcbd6200b5b Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 16 Sep 2025 18:30:22 +0200 Subject: [PATCH 03/17] Bump `imgw_pib` to version 1.5.6 (#152435) --- homeassistant/components/imgw_pib/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/imgw_pib/manifest.json b/homeassistant/components/imgw_pib/manifest.json index b0779b35f14747..6bfb9cd4324261 100644 --- a/homeassistant/components/imgw_pib/manifest.json +++ b/homeassistant/components/imgw_pib/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/imgw_pib", "iot_class": "cloud_polling", "quality_scale": "silver", - "requirements": ["imgw_pib==1.5.4"] + "requirements": ["imgw_pib==1.5.6"] } diff --git a/requirements_all.txt b/requirements_all.txt index 8f5f652906d50a..eaa924c8e6c542 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1246,7 +1246,7 @@ ihcsdk==2.8.5 imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib -imgw_pib==1.5.4 +imgw_pib==1.5.6 # homeassistant.components.incomfort incomfort-client==0.6.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1aeb9f2991a639..c338ba04b7b7a7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1080,7 +1080,7 @@ igloohome-api==0.1.1 imeon_inverter_api==0.4.0 # homeassistant.components.imgw_pib -imgw_pib==1.5.4 +imgw_pib==1.5.6 # homeassistant.components.incomfort incomfort-client==0.6.9 From 74660da2d2a227caceb2ef0e257a71d7edcea1e5 Mon Sep 17 00:00:00 2001 From: Alexandre CUER Date: Tue, 16 Sep 2025 18:32:13 +0200 Subject: [PATCH 04/17] Bump pyemoncms to 0.1.3 (#152436) --- homeassistant/components/emoncms/manifest.json | 2 +- homeassistant/components/emoncms_history/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/emoncms/manifest.json b/homeassistant/components/emoncms/manifest.json index bc86e6e9babdbc..d21da453976209 100644 --- a/homeassistant/components/emoncms/manifest.json +++ b/homeassistant/components/emoncms/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/emoncms", "iot_class": "local_polling", - "requirements": ["pyemoncms==0.1.2"] + "requirements": ["pyemoncms==0.1.3"] } diff --git a/homeassistant/components/emoncms_history/manifest.json b/homeassistant/components/emoncms_history/manifest.json index 3c8c445b766ea3..29a061f9229e5e 100644 --- a/homeassistant/components/emoncms_history/manifest.json +++ b/homeassistant/components/emoncms_history/manifest.json @@ -5,5 +5,5 @@ "documentation": "https://www.home-assistant.io/integrations/emoncms_history", "iot_class": "local_polling", "quality_scale": "legacy", - "requirements": ["pyemoncms==0.1.2"] + "requirements": ["pyemoncms==0.1.3"] } diff --git a/requirements_all.txt b/requirements_all.txt index eaa924c8e6c542..ab564ff4a01797 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1973,7 +1973,7 @@ pyegps==0.2.5 # homeassistant.components.emoncms # homeassistant.components.emoncms_history -pyemoncms==0.1.2 +pyemoncms==0.1.3 # homeassistant.components.enphase_envoy pyenphase==2.3.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c338ba04b7b7a7..4b3b4d4d0a576f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1651,7 +1651,7 @@ pyegps==0.2.5 # homeassistant.components.emoncms # homeassistant.components.emoncms_history -pyemoncms==0.1.2 +pyemoncms==0.1.3 # homeassistant.components.enphase_envoy pyenphase==2.3.0 From 87e30e090782c4603dfcc5e790f9df5d0a41b904 Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Tue, 16 Sep 2025 19:39:39 +0200 Subject: [PATCH 05/17] Fix KNX UI schema missing DPT (#152430) --- .../knx/storage/entity_store_schema.py | 22 +++++---- .../knx/snapshots/test_websocket.ambr | 48 +++++++++++++++++-- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/knx/storage/entity_store_schema.py b/homeassistant/components/knx/storage/entity_store_schema.py index 21252e35f3a7ab..934008132a8975 100644 --- a/homeassistant/components/knx/storage/entity_store_schema.py +++ b/homeassistant/components/knx/storage/entity_store_schema.py @@ -118,27 +118,31 @@ vol.Schema( { "section_binary_control": KNXSectionFlat(), - vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False), + vol.Optional(CONF_GA_UP_DOWN): GASelector(state=False, valid_dpt="1"), vol.Optional(CoverConf.INVERT_UPDOWN): selector.BooleanSelector(), "section_stop_control": KNXSectionFlat(), - vol.Optional(CONF_GA_STOP): GASelector(state=False), - vol.Optional(CONF_GA_STEP): GASelector(state=False), + vol.Optional(CONF_GA_STOP): GASelector(state=False, valid_dpt="1"), + vol.Optional(CONF_GA_STEP): GASelector(state=False, valid_dpt="1"), "section_position_control": KNXSectionFlat(collapsible=True), - vol.Optional(CONF_GA_POSITION_SET): GASelector(state=False), - vol.Optional(CONF_GA_POSITION_STATE): GASelector(write=False), + vol.Optional(CONF_GA_POSITION_SET): GASelector( + state=False, valid_dpt="5.001" + ), + vol.Optional(CONF_GA_POSITION_STATE): GASelector( + write=False, valid_dpt="5.001" + ), vol.Optional(CoverConf.INVERT_POSITION): selector.BooleanSelector(), "section_tilt_control": KNXSectionFlat(collapsible=True), - vol.Optional(CONF_GA_ANGLE): GASelector(), + vol.Optional(CONF_GA_ANGLE): GASelector(valid_dpt="5.001"), vol.Optional(CoverConf.INVERT_ANGLE): selector.BooleanSelector(), "section_travel_time": KNXSectionFlat(), - vol.Optional( + vol.Required( CoverConf.TRAVELLING_TIME_UP, default=25 ): selector.NumberSelector( selector.NumberSelectorConfig( min=0, max=1000, step=0.1, unit_of_measurement="s" ) ), - vol.Optional( + vol.Required( CoverConf.TRAVELLING_TIME_DOWN, default=25 ): selector.NumberSelector( selector.NumberSelectorConfig( @@ -310,7 +314,7 @@ class LightColorMode(StrEnum): SWITCH_KNX_SCHEMA = vol.Schema( { "section_switch": KNXSectionFlat(), - vol.Required(CONF_GA_SWITCH): GASelector(write_required=True), + vol.Required(CONF_GA_SWITCH): GASelector(write_required=True, valid_dpt="1"), vol.Optional(CONF_INVERT, default=False): selector.BooleanSelector(), vol.Optional(CONF_RESPOND_TO_READ, default=False): selector.BooleanSelector(), vol.Optional(CONF_SYNC_STATE, default=True): SyncStateSelector(), diff --git a/tests/components/knx/snapshots/test_websocket.ambr b/tests/components/knx/snapshots/test_websocket.ambr index 6dc651195aeb59..388c68e0d3f4f0 100644 --- a/tests/components/knx/snapshots/test_websocket.ambr +++ b/tests/components/knx/snapshots/test_websocket.ambr @@ -111,6 +111,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -140,6 +146,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -153,6 +165,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': False, }), @@ -172,6 +190,12 @@ 'options': dict({ 'passive': True, 'state': False, + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -187,6 +211,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': False, }), 'required': False, @@ -216,6 +246,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 5, + 'sub': 1, + }), + ]), 'write': dict({ 'required': False, }), @@ -242,8 +278,7 @@ dict({ 'default': 25, 'name': 'travelling_time_up', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -258,8 +293,7 @@ dict({ 'default': 25, 'name': 'travelling_time_down', - 'optional': True, - 'required': False, + 'required': True, 'selector': dict({ 'number': dict({ 'max': 1000.0, @@ -746,6 +780,12 @@ 'state': dict({ 'required': False, }), + 'validDPTs': list([ + dict({ + 'main': 1, + 'sub': None, + }), + ]), 'write': dict({ 'required': True, }), From c4c523e8b75e3c23f3c9d810a2b336b048acca25 Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Tue, 16 Sep 2025 19:48:47 +0200 Subject: [PATCH 06/17] Open a repair issue if Shelly Wall Display firmware is older than 2.3.0 (#152399) --- homeassistant/components/shelly/__init__.py | 2 + homeassistant/components/shelly/const.py | 4 ++ homeassistant/components/shelly/repairs.py | 51 ++++++++++++++++++-- homeassistant/components/shelly/strings.json | 15 ++++++ tests/components/shelly/test_repairs.py | 34 +++++++++++++ 5 files changed, 101 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/shelly/__init__.py b/homeassistant/components/shelly/__init__.py index d12236177b8103..c2df1ed4cb2591 100644 --- a/homeassistant/components/shelly/__init__.py +++ b/homeassistant/components/shelly/__init__.py @@ -59,6 +59,7 @@ from .repairs import ( async_manage_ble_scanner_firmware_unsupported_issue, async_manage_outbound_websocket_incorrectly_enabled_issue, + async_manage_wall_display_firmware_unsupported_issue, ) from .utils import ( async_create_issue_unsupported_firmware, @@ -328,6 +329,7 @@ async def _async_setup_rpc_entry(hass: HomeAssistant, entry: ShellyConfigEntry) await hass.config_entries.async_forward_entry_setups( entry, runtime_data.platforms ) + async_manage_wall_display_firmware_unsupported_issue(hass, entry) async_manage_ble_scanner_firmware_unsupported_issue( hass, entry, diff --git a/homeassistant/components/shelly/const.py b/homeassistant/components/shelly/const.py index 7a88f0d7c8db23..31b92f3ca587eb 100644 --- a/homeassistant/components/shelly/const.py +++ b/homeassistant/components/shelly/const.py @@ -232,6 +232,7 @@ class BLEScannerMode(StrEnum): BLE_SCANNER_MIN_FIRMWARE = "1.5.1" +WALL_DISPLAY_MIN_FIRMWARE = "2.3.0" MAX_PUSH_UPDATE_FAILURES = 5 PUSH_UPDATE_ISSUE_ID = "push_update_{unique}" @@ -244,6 +245,9 @@ class BLEScannerMode(StrEnum): OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID = ( "outbound_websocket_incorrectly_enabled_{unique}" ) +WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID = ( + "wall_display_firmware_unsupported_{unique}" +) GAS_VALVE_OPEN_STATES = ("opening", "opened") diff --git a/homeassistant/components/shelly/repairs.py b/homeassistant/components/shelly/repairs.py index e1b15f04417e5d..74203759989453 100644 --- a/homeassistant/components/shelly/repairs.py +++ b/homeassistant/components/shelly/repairs.py @@ -4,7 +4,7 @@ from typing import TYPE_CHECKING -from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3 +from aioshelly.const import MODEL_OUT_PLUG_S_G3, MODEL_PLUG_S_G3, MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, RpcCallError from aioshelly.rpc_device import RpcDevice from awesomeversion import AwesomeVersion @@ -21,6 +21,8 @@ CONF_BLE_SCANNER_MODE, DOMAIN, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, + WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID, + WALL_DISPLAY_MIN_FIRMWARE, BLEScannerMode, ) from .coordinator import ShellyConfigEntry @@ -67,6 +69,42 @@ def async_manage_ble_scanner_firmware_unsupported_issue( ir.async_delete_issue(hass, DOMAIN, issue_id) +@callback +def async_manage_wall_display_firmware_unsupported_issue( + hass: HomeAssistant, + entry: ShellyConfigEntry, +) -> None: + """Manage the Wall Display firmware unsupported issue.""" + issue_id = WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=entry.unique_id) + + if TYPE_CHECKING: + assert entry.runtime_data.rpc is not None + + device = entry.runtime_data.rpc.device + + if entry.data["model"] == MODEL_WALL_DISPLAY: + firmware = AwesomeVersion(device.shelly["ver"]) + if firmware < WALL_DISPLAY_MIN_FIRMWARE: + ir.async_create_issue( + hass, + DOMAIN, + issue_id, + is_fixable=True, + is_persistent=True, + severity=ir.IssueSeverity.WARNING, + translation_key="wall_display_firmware_unsupported", + translation_placeholders={ + "device_name": device.name, + "ip_address": device.ip_address, + "firmware": firmware, + }, + data={"entry_id": entry.entry_id}, + ) + return + + ir.async_delete_issue(hass, DOMAIN, issue_id) + + @callback def async_manage_outbound_websocket_incorrectly_enabled_issue( hass: HomeAssistant, @@ -142,8 +180,8 @@ async def _async_step_confirm(self) -> data_entry_flow.FlowResult: raise NotImplementedError -class BleScannerFirmwareUpdateFlow(ShellyRpcRepairsFlow): - """Handler for BLE Scanner Firmware Update flow.""" +class FirmwareUpdateFlow(ShellyRpcRepairsFlow): + """Handler for Firmware Update flow.""" async def _async_step_confirm(self) -> data_entry_flow.FlowResult: """Handle the confirm step of a fix flow.""" @@ -201,8 +239,11 @@ async def async_create_fix_flow( device = entry.runtime_data.rpc.device - if "ble_scanner_firmware_unsupported" in issue_id: - return BleScannerFirmwareUpdateFlow(device) + if ( + "ble_scanner_firmware_unsupported" in issue_id + or "wall_display_firmware_unsupported" in issue_id + ): + return FirmwareUpdateFlow(device) if "outbound_websocket_incorrectly_enabled" in issue_id: return DisableOutboundWebSocketFlow(device) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index e8b789c5582aef..d90b7b92a6aff8 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -288,6 +288,21 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" } } + }, + "wall_display_firmware_unsupported": { + "title": "{device_name} is running outdated firmware", + "fix_flow": { + "step": { + "confirm": { + "title": "{device_name} is running outdated firmware", + "description": "Your Shelly device {device_name} with IP address {ip_address} is running firmware {firmware}. This firmware version will not be supported by Shelly integration starting from Home Assistant 2025.11.0.\n\nSelect **Submit** button to start the OTA update to the latest stable firmware version." + } + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "update_not_available": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::fix_flow::abort::update_not_available]" + } + } } } } diff --git a/tests/components/shelly/test_repairs.py b/tests/components/shelly/test_repairs.py index 8dfd59c49baef6..d5d01402877023 100644 --- a/tests/components/shelly/test_repairs.py +++ b/tests/components/shelly/test_repairs.py @@ -2,6 +2,7 @@ from unittest.mock import Mock +from aioshelly.const import MODEL_WALL_DISPLAY from aioshelly.exceptions import DeviceConnectionError, RpcCallError import pytest @@ -10,6 +11,7 @@ CONF_BLE_SCANNER_MODE, DOMAIN, OUTBOUND_WEBSOCKET_INCORRECTLY_ENABLED_ISSUE_ID, + WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID, BLEScannerMode, ) from homeassistant.core import HomeAssistant @@ -211,3 +213,35 @@ async def test_outbound_websocket_incorrectly_enabled_issue_exc( assert issue_registry.async_get_issue(DOMAIN, issue_id) assert len(issue_registry.issues) == 1 + + +async def test_wall_display_unsupported_firmware_issue( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_rpc_device: Mock, + issue_registry: ir.IssueRegistry, +) -> None: + """Test repair issues handling for Wall Display with unsupported firmware.""" + issue_id = WALL_DISPLAY_FIRMWARE_UNSUPPORTED_ISSUE_ID.format(unique=MOCK_MAC) + assert await async_setup_component(hass, "repairs", {}) + await hass.async_block_till_done() + await init_integration(hass, 2, model=MODEL_WALL_DISPLAY) + + # The default fw version in tests is 1.0.0, the repair issue should be created. + assert issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 1 + + await async_process_repairs_platforms(hass) + client = await hass_client() + result = await start_repair_fix_flow(client, DOMAIN, issue_id) + + flow_id = result["flow_id"] + assert result["step_id"] == "confirm" + + result = await process_repair_fix_flow(client, flow_id) + assert result["type"] == "create_entry" + assert mock_rpc_device.trigger_ota_update.call_count == 1 + + # Assert the issue is no longer present + assert not issue_registry.async_get_issue(DOMAIN, issue_id) + assert len(issue_registry.issues) == 0 From 048f64eccf042e8de93860b080df33ae4ecfda27 Mon Sep 17 00:00:00 2001 From: Norbert Rittel Date: Tue, 16 Sep 2025 19:52:12 +0200 Subject: [PATCH 07/17] Improve two `unsupported_xxx` issue descriptions in `hassio` (#152387) Co-authored-by: Stefan Agner --- homeassistant/components/hassio/strings.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index b6f3d90f3ef879..94c40732f4d160 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -193,7 +193,7 @@ }, "unsupported_docker_version": { "title": "Unsupported system - Docker version", - "description": "System is unsupported because the wrong version of Docker is in use. Use the link to learn the correct version and how to fix this." + "description": "System is unsupported because the Docker version is out of date. For information about the required version and how to fix this, select Learn more." }, "unsupported_job_conditions": { "title": "Unsupported system - Protections disabled", @@ -209,7 +209,7 @@ }, "unsupported_os": { "title": "Unsupported system - Operating System", - "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. Use the link to which operating systems are supported and how to fix this." + "description": "System is unsupported because the operating system in use is not tested or maintained for use with Supervisor. For information about supported operating systems and how to fix this, select Learn more." }, "unsupported_os_agent": { "title": "Unsupported system - OS-Agent issues", From 450c47f93245ca5ad32fd53b7e98dfeb1022f5c8 Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Tue, 16 Sep 2025 20:17:43 +0200 Subject: [PATCH 08/17] Use new method to get the access token in the Volvo integration (#151625) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/volvo/__init__.py | 23 +- homeassistant/components/volvo/api.py | 18 ++ tests/components/volvo/conftest.py | 218 +++++++++++++------ tests/components/volvo/test_binary_sensor.py | 1 + tests/components/volvo/test_init.py | 13 +- tests/components/volvo/test_sensor.py | 4 + 6 files changed, 197 insertions(+), 80 deletions(-) diff --git a/homeassistant/components/volvo/__init__.py b/homeassistant/components/volvo/__init__.py index d01c74720618a7..fa2c7530cac801 100644 --- a/homeassistant/components/volvo/__init__.py +++ b/homeassistant/components/volvo/__init__.py @@ -4,9 +4,8 @@ import asyncio -from aiohttp import ClientResponseError from volvocarsapi.api import VolvoCarsApi -from volvocarsapi.models import VolvoAuthException, VolvoCarsVehicle +from volvocarsapi.models import VolvoApiException, VolvoAuthException, VolvoCarsVehicle from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant @@ -69,22 +68,22 @@ async def _async_auth_and_create_api( oauth_session = OAuth2Session(hass, entry, implementation) web_session = async_get_clientsession(hass) auth = VolvoAuth(web_session, oauth_session) - - try: - await auth.async_get_access_token() - except ClientResponseError as err: - if err.status in (400, 401): - raise ConfigEntryAuthFailed from err - - raise ConfigEntryNotReady from err - - return VolvoCarsApi( + api = VolvoCarsApi( web_session, auth, entry.data[CONF_API_KEY], entry.data[CONF_VIN], ) + try: + await api.async_get_access_token() + except VolvoAuthException as err: + raise ConfigEntryAuthFailed from err + except VolvoApiException as err: + raise ConfigEntryNotReady from err + + return api + async def _async_load_vehicle(api: VolvoCarsApi) -> VolvoCarsVehicle: try: diff --git a/homeassistant/components/volvo/api.py b/homeassistant/components/volvo/api.py index e2c1070f1ea528..bf41bcf6c2e13e 100644 --- a/homeassistant/components/volvo/api.py +++ b/homeassistant/components/volvo/api.py @@ -1,11 +1,16 @@ """API for Volvo bound to Home Assistant OAuth.""" +import logging from typing import cast from aiohttp import ClientSession from volvocarsapi.auth import AccessTokenManager from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +from homeassistant.helpers.redact import async_redact_data + +_LOGGER = logging.getLogger(__name__) +_TO_REDACT = ["access_token", "id_token", "refresh_token"] class VolvoAuth(AccessTokenManager): @@ -18,7 +23,20 @@ def __init__(self, websession: ClientSession, oauth_session: OAuth2Session) -> N async def async_get_access_token(self) -> str: """Return a valid access token.""" + current_access_token = self._oauth_session.token["access_token"] + current_refresh_token = self._oauth_session.token["refresh_token"] + await self._oauth_session.async_ensure_token_valid() + + _LOGGER.debug( + "Token: %s", async_redact_data(self._oauth_session.token, _TO_REDACT) + ) + _LOGGER.debug( + "Token changed: access %s, refresh %s", + current_access_token != self._oauth_session.token["access_token"], + current_refresh_token != self._oauth_session.token["refresh_token"], + ) + return cast(str, self._oauth_session.token["access_token"]) diff --git a/tests/components/volvo/conftest.py b/tests/components/volvo/conftest.py index fedd3a6ec3f163..92aa563d88aabe 100644 --- a/tests/components/volvo/conftest.py +++ b/tests/components/volvo/conftest.py @@ -1,6 +1,7 @@ """Define fixtures for Volvo unit tests.""" from collections.abc import AsyncGenerator, Awaitable, Callable, Generator +from dataclasses import dataclass from unittest.mock import AsyncMock, patch import pytest @@ -9,6 +10,7 @@ from volvocarsapi.models import ( VolvoCarsAvailableCommand, VolvoCarsLocation, + VolvoCarsValueField, VolvoCarsValueStatusField, VolvoCarsVehicle, ) @@ -17,10 +19,16 @@ ClientCredential, async_import_client_credential, ) +from homeassistant.components.volvo.api import VolvoAuth from homeassistant.components.volvo.const import CONF_VIN, DOMAIN from homeassistant.const import CONF_API_KEY, CONF_TOKEN from homeassistant.core import HomeAssistant +from homeassistant.helpers.config_entry_oauth2_flow import ( + OAuth2Session, + async_get_config_entry_implementation, +) from homeassistant.setup import async_setup_component +from homeassistant.util.json import JsonObjectType from . import async_load_fixture_as_json, async_load_fixture_as_value_field from .const import ( @@ -37,6 +45,30 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@dataclass +class MockApiData: + """Container for mock API data.""" + + vehicle: VolvoCarsVehicle + commands: list[VolvoCarsAvailableCommand] + location: dict[str, VolvoCarsLocation] + availability: dict[str, VolvoCarsValueField] + brakes: dict[str, VolvoCarsValueField] + diagnostics: dict[str, VolvoCarsValueField] + doors: dict[str, VolvoCarsValueField] + energy_capabilities: JsonObjectType + energy_state: dict[str, VolvoCarsValueStatusField] + engine_status: dict[str, VolvoCarsValueField] + engine_warnings: dict[str, VolvoCarsValueField] + fuel_status: dict[str, VolvoCarsValueField] + odometer: dict[str, VolvoCarsValueField] + recharge_status: dict[str, VolvoCarsValueField] + statistics: dict[str, VolvoCarsValueField] + tyres: dict[str, VolvoCarsValueField] + warnings: dict[str, VolvoCarsValueField] + windows: dict[str, VolvoCarsValueField] + + @pytest.fixture(params=[DEFAULT_MODEL]) def full_model(request: pytest.FixtureRequest) -> str: """Define which model to use when running the test. Use as a decorator.""" @@ -65,81 +97,62 @@ def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: return config_entry -@pytest.fixture(autouse=True) -async def mock_api(hass: HomeAssistant, full_model: str) -> AsyncGenerator[AsyncMock]: +@pytest.fixture +async def mock_api( + hass: HomeAssistant, + full_model: str, + mock_config_entry: MockConfigEntry, + aioclient_mock: AiohttpClientMocker, + setup_credentials, +) -> AsyncGenerator[VolvoCarsApi]: """Mock the Volvo API.""" - with patch( - "homeassistant.components.volvo.VolvoCarsApi", - autospec=True, - ) as mock_api: - vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) - vehicle = VolvoCarsVehicle.from_dict(vehicle_data) - commands_data = ( - await async_load_fixture_as_json(hass, "commands", full_model) - ).get("data") - commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + mock_api_data = await _async_load_mock_api_data(hass, full_model) - location_data = await async_load_fixture_as_json(hass, "location", full_model) - location = {"location": VolvoCarsLocation.from_dict(location_data)} + implementation = await async_get_config_entry_implementation( + hass, mock_config_entry + ) + oauth_session = OAuth2Session(hass, mock_config_entry, implementation) + auth = VolvoAuth(aioclient_mock, oauth_session) + api = VolvoCarsApi( + aioclient_mock, + auth, + mock_config_entry.data[CONF_API_KEY], + mock_config_entry.data[CONF_VIN], + ) - availability = await async_load_fixture_as_value_field( - hass, "availability", full_model - ) - brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) - diagnostics = await async_load_fixture_as_value_field( - hass, "diagnostics", full_model - ) - doors = await async_load_fixture_as_value_field(hass, "doors", full_model) - energy_capabilities = await async_load_fixture_as_json( - hass, "energy_capabilities", full_model - ) - energy_state_data = await async_load_fixture_as_json( - hass, "energy_state", full_model - ) - energy_state = { - key: VolvoCarsValueStatusField.from_dict(value) - for key, value in energy_state_data.items() - } - engine_status = await async_load_fixture_as_value_field( - hass, "engine_status", full_model + with patch( + "homeassistant.components.volvo.VolvoCarsApi", + return_value=api, + ): + api.async_get_brakes_status = AsyncMock(return_value=mock_api_data.brakes) + api.async_get_command_accessibility = AsyncMock( + return_value=mock_api_data.availability ) - engine_warnings = await async_load_fixture_as_value_field( - hass, "engine_warnings", full_model + api.async_get_commands = AsyncMock(return_value=mock_api_data.commands) + api.async_get_diagnostics = AsyncMock(return_value=mock_api_data.diagnostics) + api.async_get_doors_status = AsyncMock(return_value=mock_api_data.doors) + api.async_get_energy_capabilities = AsyncMock( + return_value=mock_api_data.energy_capabilities ) - fuel_status = await async_load_fixture_as_value_field( - hass, "fuel_status", full_model + api.async_get_energy_state = AsyncMock(return_value=mock_api_data.energy_state) + api.async_get_engine_status = AsyncMock( + return_value=mock_api_data.engine_status ) - odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) - recharge_status = await async_load_fixture_as_value_field( - hass, "recharge_status", full_model + api.async_get_engine_warnings = AsyncMock( + return_value=mock_api_data.engine_warnings ) - statistics = await async_load_fixture_as_value_field( - hass, "statistics", full_model + api.async_get_fuel_status = AsyncMock(return_value=mock_api_data.fuel_status) + api.async_get_location = AsyncMock(return_value=mock_api_data.location) + api.async_get_odometer = AsyncMock(return_value=mock_api_data.odometer) + api.async_get_recharge_status = AsyncMock( + return_value=mock_api_data.recharge_status ) - tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) - warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) - windows = await async_load_fixture_as_value_field(hass, "windows", full_model) - - api: VolvoCarsApi = mock_api.return_value - api.async_get_brakes_status = AsyncMock(return_value=brakes) - api.async_get_command_accessibility = AsyncMock(return_value=availability) - api.async_get_commands = AsyncMock(return_value=commands) - api.async_get_diagnostics = AsyncMock(return_value=diagnostics) - api.async_get_doors_status = AsyncMock(return_value=doors) - api.async_get_energy_capabilities = AsyncMock(return_value=energy_capabilities) - api.async_get_energy_state = AsyncMock(return_value=energy_state) - api.async_get_engine_status = AsyncMock(return_value=engine_status) - api.async_get_engine_warnings = AsyncMock(return_value=engine_warnings) - api.async_get_fuel_status = AsyncMock(return_value=fuel_status) - api.async_get_location = AsyncMock(return_value=location) - api.async_get_odometer = AsyncMock(return_value=odometer) - api.async_get_recharge_status = AsyncMock(return_value=recharge_status) - api.async_get_statistics = AsyncMock(return_value=statistics) - api.async_get_tyre_states = AsyncMock(return_value=tyres) - api.async_get_vehicle_details = AsyncMock(return_value=vehicle) - api.async_get_warnings = AsyncMock(return_value=warnings) - api.async_get_window_states = AsyncMock(return_value=windows) + api.async_get_statistics = AsyncMock(return_value=mock_api_data.statistics) + api.async_get_tyre_states = AsyncMock(return_value=mock_api_data.tyres) + api.async_get_vehicle_details = AsyncMock(return_value=mock_api_data.vehicle) + api.async_get_warnings = AsyncMock(return_value=mock_api_data.warnings) + api.async_get_window_states = AsyncMock(return_value=mock_api_data.windows) yield api @@ -183,3 +196,76 @@ def mock_setup_entry() -> Generator[AsyncMock]: "homeassistant.components.volvo.async_setup_entry", return_value=True ) as mock_setup: yield mock_setup + + +async def _async_load_mock_api_data( + hass: HomeAssistant, full_model: str +) -> MockApiData: + """Load all mock API data from fixtures.""" + vehicle_data = await async_load_fixture_as_json(hass, "vehicle", full_model) + vehicle = VolvoCarsVehicle.from_dict(vehicle_data) + + commands_data = ( + await async_load_fixture_as_json(hass, "commands", full_model) + ).get("data") + commands = [VolvoCarsAvailableCommand.from_dict(item) for item in commands_data] + + location_data = await async_load_fixture_as_json(hass, "location", full_model) + location = {"location": VolvoCarsLocation.from_dict(location_data)} + + availability = await async_load_fixture_as_value_field( + hass, "availability", full_model + ) + brakes = await async_load_fixture_as_value_field(hass, "brakes", full_model) + diagnostics = await async_load_fixture_as_value_field( + hass, "diagnostics", full_model + ) + doors = await async_load_fixture_as_value_field(hass, "doors", full_model) + energy_capabilities = await async_load_fixture_as_json( + hass, "energy_capabilities", full_model + ) + energy_state_data = await async_load_fixture_as_json( + hass, "energy_state", full_model + ) + energy_state = { + key: VolvoCarsValueStatusField.from_dict(value) + for key, value in energy_state_data.items() + } + engine_status = await async_load_fixture_as_value_field( + hass, "engine_status", full_model + ) + engine_warnings = await async_load_fixture_as_value_field( + hass, "engine_warnings", full_model + ) + fuel_status = await async_load_fixture_as_value_field( + hass, "fuel_status", full_model + ) + odometer = await async_load_fixture_as_value_field(hass, "odometer", full_model) + recharge_status = await async_load_fixture_as_value_field( + hass, "recharge_status", full_model + ) + statistics = await async_load_fixture_as_value_field(hass, "statistics", full_model) + tyres = await async_load_fixture_as_value_field(hass, "tyres", full_model) + warnings = await async_load_fixture_as_value_field(hass, "warnings", full_model) + windows = await async_load_fixture_as_value_field(hass, "windows", full_model) + + return MockApiData( + vehicle=vehicle, + commands=commands, + location=location, + availability=availability, + brakes=brakes, + diagnostics=diagnostics, + doors=doors, + energy_capabilities=energy_capabilities, + energy_state=energy_state, + engine_status=engine_status, + engine_warnings=engine_warnings, + fuel_status=fuel_status, + odometer=odometer, + recharge_status=recharge_status, + statistics=statistics, + tyres=tyres, + warnings=warnings, + windows=windows, + ) diff --git a/tests/components/volvo/test_binary_sensor.py b/tests/components/volvo/test_binary_sensor.py index 448a584cce93a0..e581b00595c6bf 100644 --- a/tests/components/volvo/test_binary_sensor.py +++ b/tests/components/volvo/test_binary_sensor.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", ["ex30_2024", "s90_diesel_2018", "xc40_electric_2024", "xc90_petrol_2019"], diff --git a/tests/components/volvo/test_init.py b/tests/components/volvo/test_init.py index e0e6c74b83941c..e4e08c22f39f69 100644 --- a/tests/components/volvo/test_init.py +++ b/tests/components/volvo/test_init.py @@ -21,6 +21,7 @@ from tests.test_util.aiohttp import AiohttpClientMocker +@pytest.mark.usefixtures("mock_api") async def test_setup( hass: HomeAssistant, mock_config_entry: MockConfigEntry, @@ -38,6 +39,7 @@ async def test_setup( assert mock_config_entry.state is ConfigEntryState.NOT_LOADED +@pytest.mark.usefixtures("mock_api") async def test_token_refresh_success( mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, @@ -61,7 +63,6 @@ async def test_token_refresh_success( @pytest.mark.parametrize( ("token_response"), [ - (HTTPStatus.FORBIDDEN), (HTTPStatus.INTERNAL_SERVER_ERROR), (HTTPStatus.NOT_FOUND), ], @@ -80,15 +81,23 @@ async def test_token_refresh_fail( assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY +@pytest.mark.parametrize( + ("token_response"), + [ + (HTTPStatus.BAD_REQUEST), + (HTTPStatus.FORBIDDEN), + ], +) async def test_token_refresh_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, aioclient_mock: AiohttpClientMocker, setup_integration: Callable[[], Awaitable[bool]], + token_response: HTTPStatus, ) -> None: """Test where token refresh indicates unauthorized.""" - aioclient_mock.post(TOKEN_URL, status=HTTPStatus.UNAUTHORIZED) + aioclient_mock.post(TOKEN_URL, status=token_response) assert not await setup_integration() assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR diff --git a/tests/components/volvo/test_sensor.py b/tests/components/volvo/test_sensor.py index a4b7a787117cec..988777cd7739ab 100644 --- a/tests/components/volvo/test_sensor.py +++ b/tests/components/volvo/test_sensor.py @@ -13,6 +13,7 @@ from tests.common import MockConfigEntry, snapshot_platform +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", [ @@ -38,6 +39,7 @@ async def test_sensor( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", ["xc40_electric_2024"], @@ -54,6 +56,7 @@ async def test_distance_to_empty_battery( assert hass.states.get("sensor.volvo_xc40_distance_to_empty_battery").state == "250" +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( ("full_model", "short_model"), [("ex30_2024", "ex30"), ("xc60_phev_2020", "xc60")], @@ -71,6 +74,7 @@ async def test_skip_invalid_api_fields( assert not hass.states.get(f"sensor.volvo_{short_model}_charging_current_limit") +@pytest.mark.usefixtures("mock_api", "full_model") @pytest.mark.parametrize( "full_model", ["ex30_2024"], From 3c6db923a3ec2746f2324dafe94426174feda3c2 Mon Sep 17 00:00:00 2001 From: Nathan Spencer Date: Tue, 16 Sep 2025 14:18:26 -0400 Subject: [PATCH 09/17] Deprecate Litter-Robot 4 night light mode switch (#152249) --- .../components/litterrobot/strings.json | 6 ++ .../components/litterrobot/switch.py | 77 ++++++++++++++++--- tests/components/litterrobot/conftest.py | 3 + tests/components/litterrobot/test_switch.py | 47 ++++++++++- 4 files changed, 122 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/litterrobot/strings.json b/homeassistant/components/litterrobot/strings.json index 5bb2d7ea9c72d7..58ed6fd9eec528 100644 --- a/homeassistant/components/litterrobot/strings.json +++ b/homeassistant/components/litterrobot/strings.json @@ -209,5 +209,11 @@ } } } + }, + "issues": { + "deprecated_entity": { + "title": "{name} is deprecated", + "description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue." + } } } diff --git a/homeassistant/components/litterrobot/switch.py b/homeassistant/components/litterrobot/switch.py index 310859d98a2dde..c9eff5be4c05d5 100644 --- a/homeassistant/components/litterrobot/switch.py +++ b/homeassistant/components/litterrobot/switch.py @@ -6,13 +6,24 @@ from dataclasses import dataclass from typing import Any, Generic -from pylitterbot import FeederRobot, LitterRobot, Robot +from pylitterbot import FeederRobot, LitterRobot, LitterRobot3, LitterRobot4, Robot -from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.components.switch import ( + DOMAIN as SWITCH_DOMAIN, + SwitchEntity, + SwitchEntityDescription, +) from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.issue_registry import ( + IssueSeverity, + async_create_issue, + async_delete_issue, +) +from .const import DOMAIN from .coordinator import LitterRobotConfigEntry from .entity import LitterRobotEntity, _WhiskerEntityT @@ -26,6 +37,15 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti value_fn: Callable[[_WhiskerEntityT], bool] +NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION = RobotSwitchEntityDescription[ + LitterRobot | FeederRobot +]( + key="night_light_mode_enabled", + translation_key="night_light_mode", + set_fn=lambda robot, value: robot.set_night_light(value), + value_fn=lambda robot: robot.night_light_mode_enabled, +) + SWITCH_MAP: dict[type[Robot], tuple[RobotSwitchEntityDescription, ...]] = { FeederRobot: ( RobotSwitchEntityDescription[FeederRobot]( @@ -34,14 +54,10 @@ class RobotSwitchEntityDescription(SwitchEntityDescription, Generic[_WhiskerEnti set_fn=lambda robot, value: robot.set_gravity_mode(value), value_fn=lambda robot: robot.gravity_mode_enabled, ), + NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, ), + LitterRobot3: (NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION,), Robot: ( # type: ignore[type-abstract] # only used for isinstance check - RobotSwitchEntityDescription[LitterRobot | FeederRobot]( - key="night_light_mode_enabled", - translation_key="night_light_mode", - set_fn=lambda robot, value: robot.set_night_light(value), - value_fn=lambda robot: robot.night_light_mode_enabled, - ), RobotSwitchEntityDescription[LitterRobot | FeederRobot]( key="panel_lock_enabled", translation_key="panel_lockout", @@ -59,13 +75,54 @@ async def async_setup_entry( ) -> None: """Set up Litter-Robot switches using config entry.""" coordinator = entry.runtime_data - async_add_entities( + entities = [ RobotSwitchEntity(robot=robot, coordinator=coordinator, description=description) for robot in coordinator.account.robots for robot_type, entity_descriptions in SWITCH_MAP.items() if isinstance(robot, robot_type) for description in entity_descriptions - ) + ] + + ent_reg = er.async_get(hass) + + def add_deprecated_entity( + robot: LitterRobot4, + description: RobotSwitchEntityDescription, + entity_cls: type[RobotSwitchEntity], + ) -> None: + """Add deprecated entities.""" + unique_id = f"{robot.serial}-{description.key}" + if entity_id := ent_reg.async_get_entity_id(SWITCH_DOMAIN, DOMAIN, unique_id): + entity_entry = ent_reg.async_get(entity_id) + if entity_entry and entity_entry.disabled: + ent_reg.async_remove(entity_id) + async_delete_issue( + hass, + DOMAIN, + f"deprecated_entity_{unique_id}", + ) + elif entity_entry: + entities.append(entity_cls(robot, coordinator, description)) + async_create_issue( + hass, + DOMAIN, + f"deprecated_entity_{unique_id}", + breaks_in_ha_version="2026.4.0", + is_fixable=False, + severity=IssueSeverity.WARNING, + translation_key="deprecated_entity", + translation_placeholders={ + "name": f"{robot.name} {entity_entry.name or entity_entry.original_name}", + "entity": entity_id, + }, + ) + + for robot in coordinator.account.get_robots(LitterRobot4): + add_deprecated_entity( + robot, NIGHT_LIGHT_MODE_ENTITY_DESCRIPTION, RobotSwitchEntity + ) + + async_add_entities(entities) class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity): diff --git a/tests/components/litterrobot/conftest.py b/tests/components/litterrobot/conftest.py index 5075b5d5efd7ee..f13d0f82d2bd4e 100644 --- a/tests/components/litterrobot/conftest.py +++ b/tests/components/litterrobot/conftest.py @@ -84,6 +84,9 @@ def create_mock_account( if skip_robots else [create_mock_robot(robot_data, account, v4, feeder, side_effect)] ) + account.get_robots = lambda robot_class: [ + robot for robot in account.robots if isinstance(robot, robot_class) + ] account.pets = [create_mock_pet(PET_DATA, account, side_effect)] if pet else [] return account diff --git a/tests/components/litterrobot/test_switch.py b/tests/components/litterrobot/test_switch.py index a1ccddc79d177f..3991bdbbab0dfc 100644 --- a/tests/components/litterrobot/test_switch.py +++ b/tests/components/litterrobot/test_switch.py @@ -5,6 +5,7 @@ from pylitterbot import FeederRobot, Robot import pytest +from homeassistant.components.litterrobot import DOMAIN from homeassistant.components.switch import ( DOMAIN as PLATFORM_DOMAIN, SERVICE_TURN_OFF, @@ -12,7 +13,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON, EntityCategory 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 .conftest import setup_integration @@ -90,3 +91,47 @@ async def test_feeder_robot_switch( assert robot.set_gravity_mode.call_count == count + 1 assert (state := hass.states.get(gravity_mode_switch)) assert state.state == new_state + + +@pytest.mark.parametrize( + ("preexisting_entity", "disabled_by", "expected_entity", "expected_issue"), + [ + (True, None, True, True), + (True, er.RegistryEntryDisabler.USER, False, False), + (False, None, False, False), + ], +) +async def test_litterrobot_4_deprecated_switch( + hass: HomeAssistant, + mock_account_with_litterrobot_4: MagicMock, + issue_registry: ir.IssueRegistry, + entity_registry: er.EntityRegistry, + preexisting_entity: bool, + disabled_by: er.RegistryEntryDisabler, + expected_entity: bool, + expected_issue: bool, +) -> None: + """Test switch deprecation issue.""" + entity_uid = "LR4C010001-night_light_mode_enabled" + if preexisting_entity: + suggested_id = NIGHT_LIGHT_MODE_ENTITY_ID.replace(f"{PLATFORM_DOMAIN}.", "") + entity_registry.async_get_or_create( + PLATFORM_DOMAIN, + DOMAIN, + entity_uid, + suggested_object_id=suggested_id, + disabled_by=disabled_by, + ) + + await setup_integration(hass, mock_account_with_litterrobot_4, PLATFORM_DOMAIN) + + assert ( + entity_registry.async_get(NIGHT_LIGHT_MODE_ENTITY_ID) is not None + ) is expected_entity + assert ( + issue_registry.async_get_issue( + domain=DOMAIN, + issue_id=f"deprecated_entity_{entity_uid}", + ) + is not None + ) is expected_issue From df16e85359e35903bd91b8368488ad958eaee8ae Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 16 Sep 2025 21:23:10 +0300 Subject: [PATCH 10/17] Fix typo in update_not_available key in Shelly strings (#152444) --- homeassistant/components/shelly/strings.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index d90b7b92a6aff8..1a11ecbb499321 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -300,7 +300,7 @@ }, "abort": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "update_not_available": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::fix_flow::abort::update_not_available]" + "update_not_available": "[%key:component::shelly::issues::ble_scanner_firmware_unsupported::fix_flow::abort::update_not_available%]" } } } From 770f41d07918ea0be0ab43c0446f47a030edf8b2 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Tue, 16 Sep 2025 11:24:05 -0700 Subject: [PATCH 11/17] Diagnostics for derivative sensor (#152445) --- .../components/derivative/diagnostics.py | 23 ++++++ tests/components/derivative/conftest.py | 74 +++++++++++++++++++ .../components/derivative/test_diagnostics.py | 24 ++++++ tests/components/derivative/test_init.py | 64 ---------------- 4 files changed, 121 insertions(+), 64 deletions(-) create mode 100644 homeassistant/components/derivative/diagnostics.py create mode 100644 tests/components/derivative/conftest.py create mode 100644 tests/components/derivative/test_diagnostics.py diff --git a/homeassistant/components/derivative/diagnostics.py b/homeassistant/components/derivative/diagnostics.py new file mode 100644 index 00000000000000..4f5496d72fe79b --- /dev/null +++ b/homeassistant/components/derivative/diagnostics.py @@ -0,0 +1,23 @@ +"""Diagnostics support for derivative.""" + +from __future__ import annotations + +from typing import Any + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, config_entry: ConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + + registry = er.async_get(hass) + entities = registry.entities.get_entries_for_config_entry_id(config_entry.entry_id) + + return { + "config_entry": config_entry.as_dict(), + "entity": [entity.extended_dict for entity in entities], + } diff --git a/tests/components/derivative/conftest.py b/tests/components/derivative/conftest.py new file mode 100644 index 00000000000000..223787d842d510 --- /dev/null +++ b/tests/components/derivative/conftest.py @@ -0,0 +1,74 @@ +"""Fixtures for derivative tests.""" + +import pytest + +from homeassistant.components.derivative.config_flow import ConfigFlowHandler +from homeassistant.components.derivative.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry + + +@pytest.fixture +def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: + """Fixture to create a sensor config entry.""" + sensor_config_entry = MockConfigEntry() + sensor_config_entry.add_to_hass(hass) + return sensor_config_entry + + +@pytest.fixture +def sensor_device( + device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry +) -> dr.DeviceEntry: + """Fixture to create a sensor device.""" + return device_registry.async_get_or_create( + config_entry_id=sensor_config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + + +@pytest.fixture +def sensor_entity_entry( + entity_registry: er.EntityRegistry, + sensor_config_entry: ConfigEntry, + sensor_device: dr.DeviceEntry, +) -> er.RegistryEntry: + """Fixture to create a sensor entity entry.""" + return entity_registry.async_get_or_create( + "sensor", + "test", + "unique", + config_entry=sensor_config_entry, + device_id=sensor_device.id, + original_name="ABC", + ) + + +@pytest.fixture +def derivative_config_entry( + hass: HomeAssistant, + sensor_entity_entry: er.RegistryEntry, +) -> MockConfigEntry: + """Fixture to create a derivative config entry.""" + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "name": "My derivative", + "round": 1.0, + "source": sensor_entity_entry.entity_id, + "time_window": {"seconds": 0.0}, + "unit_prefix": "k", + "unit_time": "min", + }, + title="My derivative", + version=ConfigFlowHandler.VERSION, + minor_version=ConfigFlowHandler.MINOR_VERSION, + ) + + config_entry.add_to_hass(hass) + + return config_entry diff --git a/tests/components/derivative/test_diagnostics.py b/tests/components/derivative/test_diagnostics.py new file mode 100644 index 00000000000000..98ceaba1c55d58 --- /dev/null +++ b/tests/components/derivative/test_diagnostics.py @@ -0,0 +1,24 @@ +"""Tests for derivative diagnostics.""" + +from homeassistant.core import HomeAssistant + +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +async def test_diagnostics( + hass: HomeAssistant, hass_client: ClientSessionGenerator, derivative_config_entry +) -> None: + """Test diagnostics for config entry.""" + + assert await hass.config_entries.async_setup(derivative_config_entry.entry_id) + await hass.async_block_till_done() + + result = await get_diagnostics_for_config_entry( + hass, hass_client, derivative_config_entry + ) + + assert isinstance(result, dict) + assert result["config_entry"]["domain"] == "derivative" + assert result["config_entry"]["options"]["name"] == "My derivative" + assert result["entity"][0]["entity_id"] == "sensor.my_derivative" diff --git a/tests/components/derivative/test_init.py b/tests/components/derivative/test_init.py index 005e6ec91d9012..f2f505bd2e719a 100644 --- a/tests/components/derivative/test_init.py +++ b/tests/components/derivative/test_init.py @@ -5,7 +5,6 @@ import pytest from homeassistant.components import derivative -from homeassistant.components.derivative.config_flow import ConfigFlowHandler from homeassistant.components.derivative.const import DOMAIN from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import Event, HomeAssistant, callback @@ -15,69 +14,6 @@ from tests.common import MockConfigEntry -@pytest.fixture -def sensor_config_entry(hass: HomeAssistant) -> er.RegistryEntry: - """Fixture to create a sensor config entry.""" - sensor_config_entry = MockConfigEntry() - sensor_config_entry.add_to_hass(hass) - return sensor_config_entry - - -@pytest.fixture -def sensor_device( - device_registry: dr.DeviceRegistry, sensor_config_entry: ConfigEntry -) -> dr.DeviceEntry: - """Fixture to create a sensor device.""" - return device_registry.async_get_or_create( - config_entry_id=sensor_config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, - ) - - -@pytest.fixture -def sensor_entity_entry( - entity_registry: er.EntityRegistry, - sensor_config_entry: ConfigEntry, - sensor_device: dr.DeviceEntry, -) -> er.RegistryEntry: - """Fixture to create a sensor entity entry.""" - return entity_registry.async_get_or_create( - "sensor", - "test", - "unique", - config_entry=sensor_config_entry, - device_id=sensor_device.id, - original_name="ABC", - ) - - -@pytest.fixture -def derivative_config_entry( - hass: HomeAssistant, - sensor_entity_entry: er.RegistryEntry, -) -> MockConfigEntry: - """Fixture to create a derivative config entry.""" - config_entry = MockConfigEntry( - data={}, - domain=DOMAIN, - options={ - "name": "My derivative", - "round": 1.0, - "source": sensor_entity_entry.entity_id, - "time_window": {"seconds": 0.0}, - "unit_prefix": "k", - "unit_time": "min", - }, - title="My derivative", - version=ConfigFlowHandler.VERSION, - minor_version=ConfigFlowHandler.MINOR_VERSION, - ) - - config_entry.add_to_hass(hass) - - return config_entry - - def track_entity_registry_actions(hass: HomeAssistant, entity_id: str) -> list[str]: """Track entity registry actions for an entity.""" events = [] From 7f1314129717b75f2d3f990c347a7cc1d1d3d852 Mon Sep 17 00:00:00 2001 From: Shay Levy Date: Tue, 16 Sep 2025 21:25:09 +0300 Subject: [PATCH 12/17] Bump aioshelly 13.10.0 (#152442) --- homeassistant/components/shelly/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/shelly/manifest.json b/homeassistant/components/shelly/manifest.json index 119f2b95a7e862..7c3292f5dea3ae 100644 --- a/homeassistant/components/shelly/manifest.json +++ b/homeassistant/components/shelly/manifest.json @@ -9,7 +9,7 @@ "iot_class": "local_push", "loggers": ["aioshelly"], "quality_scale": "silver", - "requirements": ["aioshelly==13.9.0"], + "requirements": ["aioshelly==13.10.0"], "zeroconf": [ { "type": "_http._tcp.local.", diff --git a/requirements_all.txt b/requirements_all.txt index ab564ff4a01797..2970034e851b7d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -384,7 +384,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.9.0 +aioshelly==13.10.0 # homeassistant.components.skybell aioskybell==22.7.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 4b3b4d4d0a576f..70c3d25d49b89c 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -366,7 +366,7 @@ aioruuvigateway==0.1.0 aiosenz==1.0.0 # homeassistant.components.shelly -aioshelly==13.9.0 +aioshelly==13.10.0 # homeassistant.components.skybell aioskybell==22.7.0 From 23fa84e20eb4e3f890f8727ca81a77157ec9741c Mon Sep 17 00:00:00 2001 From: Robert Resch Date: Tue, 16 Sep 2025 20:55:44 +0200 Subject: [PATCH 13/17] Verify that Ecovacs integration is setup without any errors in the tests (#152447) --- tests/components/ecovacs/conftest.py | 9 +++++++++ tests/components/ecovacs/test_init.py | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/tests/components/ecovacs/conftest.py b/tests/components/ecovacs/conftest.py index 22039d6c0bc1d0..b33ed28c94454b 100644 --- a/tests/components/ecovacs/conftest.py +++ b/tests/components/ecovacs/conftest.py @@ -1,6 +1,7 @@ """Common fixtures for the Ecovacs tests.""" from collections.abc import AsyncGenerator, Generator +import logging from typing import Any from unittest.mock import AsyncMock, Mock, patch @@ -134,6 +135,7 @@ def mock_vacbot(device_fixture: str) -> Generator[Mock]: vacbot.lifespanEvents = EventEmitter() vacbot.errorEvents = EventEmitter() vacbot.battery_status = None + vacbot.charge_status = None vacbot.fan_speed = None vacbot.components = {} yield vacbot @@ -159,6 +161,7 @@ def platforms() -> Platform | list[Platform]: @pytest.fixture async def init_integration( hass: HomeAssistant, + caplog: pytest.LogCaptureFixture, mock_config_entry: MockConfigEntry, mock_authenticator: Mock, mock_mqtt_client: Mock, @@ -177,6 +180,12 @@ async def init_integration( await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done(wait_background_tasks=True) + + # No errors should be logged during setup + assert not [t for t in caplog.record_tuples if t[1] >= logging.ERROR], ( + "Errors during integration setup" + ) + yield mock_config_entry diff --git a/tests/components/ecovacs/test_init.py b/tests/components/ecovacs/test_init.py index 3115f1b4040d4d..5965398bd0cfb1 100644 --- a/tests/components/ecovacs/test_init.py +++ b/tests/components/ecovacs/test_init.py @@ -107,7 +107,7 @@ async def test_devices_in_dr( [ ("yna5x1", 26), ("5xu9h3", 25), - ("123", 2), + ("123", 3), ], ) async def test_all_entities_loaded( From 2596ab294061898ce479a88734233b76f58dff7b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 16 Sep 2025 15:11:46 -0400 Subject: [PATCH 14/17] OpenAI to use provided mimetype when available (#152407) --- .../openai_conversation/__init__.py | 2 +- .../components/openai_conversation/entity.py | 37 +++++++++++-------- .../openai_conversation/test_ai_task.py | 15 ++++---- 3 files changed, 29 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 06a61d70b01113..b4c9a16693a353 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -148,7 +148,7 @@ async def send_prompt(call: ServiceCall) -> ServiceResponse: content.extend( await async_prepare_files_for_prompt( - hass, [Path(filename) for filename in filenames] + hass, [(Path(filename), None) for filename in filenames] ) ) diff --git a/homeassistant/components/openai_conversation/entity.py b/homeassistant/components/openai_conversation/entity.py index 95311830ec94db..4d2c62a7a8c200 100644 --- a/homeassistant/components/openai_conversation/entity.py +++ b/homeassistant/components/openai_conversation/entity.py @@ -223,15 +223,17 @@ def _convert_content_to_param( ResponseReasoningItemParam( type="reasoning", id=content.native.id, - summary=[ - { - "type": "summary_text", - "text": summary, - } - for summary in reasoning_summary - ] - if content.thinking_content - else [], + summary=( + [ + { + "type": "summary_text", + "text": summary, + } + for summary in reasoning_summary + ] + if content.thinking_content + else [] + ), encrypted_content=content.native.encrypted_content, ) ) @@ -308,9 +310,11 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have "tool_call_id": event.item.id, "tool_name": "code_interpreter", "tool_result": { - "output": [output.to_dict() for output in event.item.outputs] # type: ignore[misc] - if event.item.outputs is not None - else None + "output": ( + [output.to_dict() for output in event.item.outputs] # type: ignore[misc] + if event.item.outputs is not None + else None + ) }, } last_role = "tool_result" @@ -529,7 +533,7 @@ async def _async_handle_chat_log( if last_content.role == "user" and last_content.attachments: files = await async_prepare_files_for_prompt( self.hass, - [a.path for a in last_content.attachments], + [(a.path, a.mime_type) for a in last_content.attachments], ) last_message = messages[-1] assert ( @@ -601,7 +605,7 @@ async def _async_handle_chat_log( async def async_prepare_files_for_prompt( - hass: HomeAssistant, files: list[Path] + hass: HomeAssistant, files: list[tuple[Path, str | None]] ) -> ResponseInputMessageContentListParam: """Append files to a prompt. @@ -611,11 +615,12 @@ async def async_prepare_files_for_prompt( def append_files_to_content() -> ResponseInputMessageContentListParam: content: ResponseInputMessageContentListParam = [] - for file_path in files: + for file_path, mime_type in files: if not file_path.exists(): raise HomeAssistantError(f"`{file_path}` does not exist") - mime_type, _ = guess_file_type(file_path) + if mime_type is None: + mime_type = guess_file_type(file_path)[0] if not mime_type or not mime_type.startswith(("image/", "application/pdf")): raise HomeAssistantError( diff --git a/tests/components/openai_conversation/test_ai_task.py b/tests/components/openai_conversation/test_ai_task.py index 51ac505893e119..b9a69e5f77ed61 100644 --- a/tests/components/openai_conversation/test_ai_task.py +++ b/tests/components/openai_conversation/test_ai_task.py @@ -155,14 +155,13 @@ async def test_generate_data_with_attachments( path=Path("doorbell_snapshot.jpg"), ), media_source.PlayMedia( - url="http://example.com/context.txt", - mime_type="text/plain", - path=Path("context.txt"), + url="http://example.com/context.pdf", + mime_type="application/pdf", + path=Path("context.pdf"), ), ], ), patch("pathlib.Path.exists", return_value=True), - # patch.object(hass.config, "is_allowed_path", return_value=True), patch( "homeassistant.components.openai_conversation.entity.guess_file_type", return_value=("image/jpeg", None), @@ -176,7 +175,7 @@ async def test_generate_data_with_attachments( instructions="Test prompt", attachments=[ {"media_content_id": "media-source://media/doorbell_snapshot.jpg"}, - {"media_content_id": "media-source://media/context.txt"}, + {"media_content_id": "media-source://media/context.pdf"}, ], ) @@ -205,9 +204,9 @@ async def test_generate_data_with_attachments( "type": "input_image", }, { - "detail": "auto", - "image_url": "data:image/jpeg;base64,ZmFrZV9pbWFnZV9kYXRh", - "type": "input_image", + "filename": "context.pdf", + "file_data": "data:application/pdf;base64,ZmFrZV9pbWFnZV9kYXRh", + "type": "input_file", }, ] From 24fc8b929794f7fec83c7495432e1449c9ab18ce Mon Sep 17 00:00:00 2001 From: Yevhenii Vaskivskyi Date: Tue, 16 Sep 2025 21:18:29 +0200 Subject: [PATCH 15/17] Fix bug with the hardcoded configuration_url (asuswrt) (#151858) --- homeassistant/components/asuswrt/bridge.py | 7 +++++++ homeassistant/components/asuswrt/router.py | 2 +- tests/components/asuswrt/conftest.py | 4 ++++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/asuswrt/bridge.py b/homeassistant/components/asuswrt/bridge.py index ce0910fcb89ce9..ae6cbc1c82a46f 100644 --- a/homeassistant/components/asuswrt/bridge.py +++ b/homeassistant/components/asuswrt/bridge.py @@ -120,6 +120,7 @@ def get_bridge( def __init__(self, host: str) -> None: """Initialize Bridge.""" + self._configuration_url = f"http://{host}" self._host = host self._firmware: str | None = None self._label_mac: str | None = None @@ -127,6 +128,11 @@ def __init__(self, host: str) -> None: self._model_id: str | None = None self._serial_number: str | None = None + @property + def configuration_url(self) -> str: + """Return configuration URL.""" + return self._configuration_url + @property def host(self) -> str: """Return hostname.""" @@ -371,6 +377,7 @@ async def async_connect(self) -> None: # get main router properties if mac := _identity.mac: self._label_mac = format_mac(mac) + self._configuration_url = self._api.webpanel self._firmware = str(_identity.firmware) self._model = _identity.model self._model_id = _identity.product_id diff --git a/homeassistant/components/asuswrt/router.py b/homeassistant/components/asuswrt/router.py index 9e23560b6f78d6..3631c7a25bb725 100644 --- a/homeassistant/components/asuswrt/router.py +++ b/homeassistant/components/asuswrt/router.py @@ -388,13 +388,13 @@ def update_options(self, new_options: Mapping[str, Any]) -> bool: def device_info(self) -> DeviceInfo: """Return the device information.""" info = DeviceInfo( + configuration_url=self._api.configuration_url, identifiers={(DOMAIN, self._entry.unique_id or "AsusWRT")}, name=self.host, model=self._api.model or "Asus Router", model_id=self._api.model_id, serial_number=self._api.serial_number, manufacturer="Asus", - configuration_url=f"http://{self.host}", ) if self._api.firmware: info["sw_version"] = self._api.firmware diff --git a/tests/components/asuswrt/conftest.py b/tests/components/asuswrt/conftest.py index 95c8f3dbf747e1..3741aa44559e55 100644 --- a/tests/components/asuswrt/conftest.py +++ b/tests/components/asuswrt/conftest.py @@ -12,6 +12,7 @@ from .common import ( ASUSWRT_BASE, + HOST, MOCK_MACS, PROTOCOL_HTTP, PROTOCOL_SSH, @@ -155,6 +156,9 @@ def mock_controller_connect_http(mock_devices_http): # Simulate connection status instance.connected = True + # Set the webpanel address + instance.webpanel = f"http://{HOST}:80" + # Identity instance.async_get_identity.return_value = AsusDevice( mac=ROUTER_MAC_ADDR, From 462fa77ba194301b6c2b13bc197d19084937658d Mon Sep 17 00:00:00 2001 From: Daniel Jansen Date: Tue, 16 Sep 2025 21:24:51 +0200 Subject: [PATCH 16/17] Improve waze_travel_time tests (#146495) Co-authored-by: Erik Montnemery --- .../components/waze_travel_time/test_init.py | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/tests/components/waze_travel_time/test_init.py b/tests/components/waze_travel_time/test_init.py index d11bca524e98fa..dae11d58409e0a 100644 --- a/tests/components/waze_travel_time/test_init.py +++ b/tests/components/waze_travel_time/test_init.py @@ -62,6 +62,33 @@ async def test_service_get_travel_times(hass: HomeAssistant) -> None: } +@pytest.mark.parametrize( + ("data", "options"), + [(MOCK_CONFIG, DEFAULT_OPTIONS)], +) +@pytest.mark.usefixtures("mock_update", "mock_config") +async def test_service_get_travel_times_empty_response( + hass: HomeAssistant, mock_update +) -> None: + """Test service get_travel_times.""" + mock_update.return_value = [] + response_data = await hass.services.async_call( + "waze_travel_time", + "get_travel_times", + { + "origin": "location1", + "destination": "location2", + "vehicle_type": "car", + "region": "us", + "units": "imperial", + "incl_filter": ["IncludeThis"], + }, + blocking=True, + return_response=True, + ) + assert response_data == {"routes": []} + + @pytest.mark.usefixtures("mock_update") async def test_migrate_entry_v1_v2(hass: HomeAssistant) -> None: """Test successful migration of entry data.""" From 823071b7221da4cb1293db9f0ece8f79965f8410 Mon Sep 17 00:00:00 2001 From: GSzabados <35445496+GSzabados@users.noreply.github.com> Date: Tue, 16 Sep 2025 21:33:47 +0200 Subject: [PATCH 17/17] Add LDS01 support (#151820) Co-authored-by: Robert Resch --- homeassistant/components/ecowitt/sensor.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/homeassistant/components/ecowitt/sensor.py b/homeassistant/components/ecowitt/sensor.py index 167e1f70c2c5b4..631910bde86527 100644 --- a/homeassistant/components/ecowitt/sensor.py +++ b/homeassistant/components/ecowitt/sensor.py @@ -234,6 +234,17 @@ native_unit_of_measurement=PERCENTAGE, state_class=SensorStateClass.MEASUREMENT, ), + EcoWittSensorTypes.DISTANCE_MM: SensorEntityDescription( + key="DISTANCE_MM", + device_class=SensorDeviceClass.DISTANCE, + native_unit_of_measurement=UnitOfLength.MILLIMETERS, + state_class=SensorStateClass.MEASUREMENT, + ), + EcoWittSensorTypes.HEAT_COUNT: SensorEntityDescription( + key="HEAT_COUNT", + state_class=SensorStateClass.TOTAL_INCREASING, + entity_category=EntityCategory.DIAGNOSTIC, + ), EcoWittSensorTypes.PM1: SensorEntityDescription( key="PM1", device_class=SensorDeviceClass.PM1,