diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fc7658aa829306..4d6bfac215d2f5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.16.4 + rev: v0.16.5 hooks: - id: ruff-check args: diff --git a/homeassistant/components/environment_canada/manifest.json b/homeassistant/components/environment_canada/manifest.json index e6389064f3106c..54ac2c8e7b13e4 100644 --- a/homeassistant/components/environment_canada/manifest.json +++ b/homeassistant/components/environment_canada/manifest.json @@ -7,5 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["env_canada"], - "requirements": ["env-canada==0.19.0"] + "requirements": ["env-canada==0.19.2"] } diff --git a/homeassistant/components/hotspring/config_flow.py b/homeassistant/components/hotspring/config_flow.py index 1377c7cf5e6327..9967c88f65a2c0 100644 --- a/homeassistant/components/hotspring/config_flow.py +++ b/homeassistant/components/hotspring/config_flow.py @@ -3,7 +3,13 @@ from collections.abc import Mapping from typing import Any, override -from hotspring import HotSpring, HotSpringConnectionError, HotSpringError, Spa +from hotspring import ( + HotSpring, + HotSpringConnectionError, + HotSpringError, + HotSpringSNADetectedError, + Spa, +) import voluptuous as vol from homeassistant.config_entries import ( @@ -52,6 +58,8 @@ async def async_step_user( if user_input is not None: try: spa = await validate_input(self.hass, user_input) + except HotSpringSNADetectedError: + errors["base"] = "sna_device" except HotSpringConnectionError, HotSpringError: errors["base"] = "cannot_connect" else: @@ -101,6 +109,8 @@ async def async_step_zeroconf( self.discovered_spa = await validate_input( self.hass, {CONF_HOST: discovery_info.host} ) + except HotSpringSNADetectedError: + return self.async_abort(reason="sna_device") except HotSpringConnectionError, HotSpringError: return self.async_abort(reason="cannot_connect") diff --git a/homeassistant/components/hotspring/manifest.json b/homeassistant/components/hotspring/manifest.json index 5c8154b57b7674..3b577f5df72d9e 100644 --- a/homeassistant/components/hotspring/manifest.json +++ b/homeassistant/components/hotspring/manifest.json @@ -8,7 +8,7 @@ "iot_class": "local_polling", "loggers": ["hotspring"], "quality_scale": "platinum", - "requirements": ["python-hotspring==2.0.1"], + "requirements": ["python-hotspring==2.1.0"], "zeroconf": [ { "name": "watkins_spa*", diff --git a/homeassistant/components/hotspring/strings.json b/homeassistant/components/hotspring/strings.json index 4ae8352038d3e5..8d06bab200b548 100644 --- a/homeassistant/components/hotspring/strings.json +++ b/homeassistant/components/hotspring/strings.json @@ -4,10 +4,12 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", + "sna_device": "The discovered device is a Spa Network Adapter (SNA). Only the Home Network Adapter (HNA) can be configured.", "unique_id_mismatch": "The MAC address does not match the configured device. Please ensure you reconfigure against the same device." }, "error": { - "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "sna_device": "The entered address belongs to the Spa Network Adapter (SNA). Please enter the IP address or hostname of your Home Network Adapter (HNA) instead." }, "step": { "user": { diff --git a/homeassistant/components/imou/icons.json b/homeassistant/components/imou/icons.json index 778574ddb2d016..aad37b91a2cd28 100644 --- a/homeassistant/components/imou/icons.json +++ b/homeassistant/components/imou/icons.json @@ -15,6 +15,9 @@ } }, "select": { + "collection_point": { + "default": "mdi:crosshairs-gps" + }, "device_volume": { "default": "mdi:volume-high", "state": { diff --git a/homeassistant/components/imou/select.py b/homeassistant/components/imou/select.py index a4be6634e6c18a..d15b3d804df4ef 100644 --- a/homeassistant/components/imou/select.py +++ b/homeassistant/components/imou/select.py @@ -3,6 +3,7 @@ from typing import override from pyimouapi.const import ( + PARAM_COLLECTION_POINT, PARAM_CURRENT_OPTION, PARAM_DEVICE_VOLUME, PARAM_NIGHT_VISION_MODE, @@ -33,6 +34,10 @@ entity_category=EntityCategory.CONFIG, translation_key=PARAM_NIGHT_VISION_MODE, ), + SelectEntityDescription( + key=PARAM_COLLECTION_POINT, + translation_key=PARAM_COLLECTION_POINT, + ), ) diff --git a/homeassistant/components/imou/strings.json b/homeassistant/components/imou/strings.json index 051f0d3a8d2bc1..66c1928a1c033c 100644 --- a/homeassistant/components/imou/strings.json +++ b/homeassistant/components/imou/strings.json @@ -63,6 +63,12 @@ } }, "select": { + "collection_point": { + "name": "Go to collection point", + "state": { + "select_collection_point": "Select a collection point…" + } + }, "device_volume": { "name": "Volume", "state": { diff --git a/homeassistant/components/jewish_calendar/calendar.py b/homeassistant/components/jewish_calendar/calendar.py index 6fcb0492138107..4261631c3d56de 100644 --- a/homeassistant/components/jewish_calendar/calendar.py +++ b/homeassistant/components/jewish_calendar/calendar.py @@ -8,6 +8,8 @@ from hdate import HDateInfo, Zmanim from hdate.parasha import Parasha +from hdate.translator import TranslatorMixin, set_language +from hdate.zmanim import Zman from homeassistant.components.calendar import ( CalendarEntity, @@ -51,6 +53,26 @@ class JewishCalendarCalendarEntityDescription(CalendarEntityDescription): ] +def _all_day_event(target_date: date, item: TranslatorMixin) -> CalendarEvent: + """Create an all-day event from a translatable hdate object.""" + return CalendarEvent( + start=target_date, + end=target_date, + summary=str(item), + description=item.description, + ) + + +def _timed_event(zman: Zman) -> CalendarEvent: + """Create an instantaneous event from a zman.""" + return CalendarEvent( + start=zman.utc, + end=zman.utc, + summary=str(zman), + description=zman.description, + ) + + def _create_daily_event( event_type: JewishCalendarEventType, target_date: date, @@ -58,28 +80,13 @@ def _create_daily_event( zmanim: Zmanim, ) -> CalendarEvent | None: """Create a daily calendar event.""" - # Hebrew date if event_type == DailyCalendarEventType.DATE: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.hdate), - description=f"Hebrew date: {info.hdate}", - ) + return _all_day_event(target_date, info.hdate) - # Time-based daily events using enum properties daily_event = DailyCalendarEventType(event_type) - time_value = zmanim.zmanim.get(daily_event.value) - - if time_value is not None: - return CalendarEvent( - start=time_value.utc, - end=time_value.utc, - summary=daily_event.summary, - description=f"{daily_event.description_prefix}: {time_value.local.strftime('%H:%M')}", - ) - - return None # Should never happen + if (zman := zmanim.zmanim.get(daily_event.value)) is None: + return None # Should never happen + return _timed_event(zman) def _create_yearly_event( @@ -89,56 +96,30 @@ def _create_yearly_event( zmanim: Zmanim, ) -> list[CalendarEvent] | CalendarEvent | None: """Create a yearly calendar event.""" - if event_type == YearlyCalendarEventType.HOLIDAY and info.holidays: - return [ - CalendarEvent( - start=target_date, - end=target_date, - summary=str(holiday), - description=( - f"Jewish Holiday: {holiday}\nHoliday Type: {holiday.type}" - ), - ) - for holiday in info.holidays - ] + if event_type == YearlyCalendarEventType.HOLIDAY: + return [_all_day_event(target_date, holiday) for holiday in info.holidays] if event_type == YearlyCalendarEventType.WEEKLY_PORTION: is_shabbat = target_date.weekday() == _SATURDAY is_simchat_torah = any( holiday.name == _SIMCHAT_TORAH for holiday in info.holidays ) - if (is_shabbat or is_simchat_torah) and info.parasha != str(Parasha.NONE): - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.parasha), - description=f"Parshat Hashavua: {info.parasha}", - ) + parasha = info.parasha_obj + if (is_shabbat or is_simchat_torah) and parasha is not Parasha.NONE: + return _all_day_event(target_date, parasha) return None - if event_type == YearlyCalendarEventType.OMER_COUNT and info.omer.total_days > 0: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.omer), - description=f"Sefirat HaOmer: {info.omer.count_str()}", - ) + if event_type == YearlyCalendarEventType.OMER_COUNT: + omer = info.omer + return _all_day_event(target_date, omer) if omer.total_days > 0 else None - if event_type == YearlyCalendarEventType.CANDLE_LIGHTING and zmanim.candle_lighting: - return CalendarEvent( - start=zmanim.candle_lighting.astimezone(UTC), - end=zmanim.candle_lighting.astimezone(UTC), - summary="Candle Lighting", - description=f"Candle lighting time: {zmanim.candle_lighting.strftime('%H:%M')}", - ) + if event_type == YearlyCalendarEventType.CANDLE_LIGHTING: + zman = zmanim.candle_lighting_obj + return _timed_event(zman) if zman is not None else None - if event_type == YearlyCalendarEventType.HAVDALAH and zmanim.havdalah: - return CalendarEvent( - start=zmanim.havdalah.astimezone(UTC), - end=zmanim.havdalah.astimezone(UTC), - summary="Havdalah", - description=f"Havdalah time: {zmanim.havdalah.strftime('%H:%M')}", - ) + if event_type == YearlyCalendarEventType.HAVDALAH: + zman = zmanim.havdalah_obj + return _timed_event(zman) if zman is not None else None return None @@ -150,13 +131,8 @@ def _create_learning_event( zmanim: Zmanim, ) -> CalendarEvent | None: """Create a learning schedule event.""" - if event_type == LearningScheduleEventType.DAF_YOMI and info.daf_yomi: - return CalendarEvent( - start=target_date, - end=target_date, - summary=str(info.daf_yomi), - description=f"Daf Yomi: {info.daf_yomi}", - ) + if event_type == LearningScheduleEventType.DAF_YOMI: + return _all_day_event(target_date, info.daf_yomi_obj) return None @@ -277,6 +253,10 @@ def _date_to_dt(self, val: date | datetime, _time: time) -> datetime: def _get_events_for_date(self, target_date: date) -> list[CalendarEvent]: """Get all configured events for a specific date.""" + # hdate holds its display language in a ContextVar that the coordinator sets + # in its own task, so it has to be re-applied in the task serving this request. + set_language(self.coordinator.data.language) + events = [] info = HDateInfo(target_date, self.coordinator.data.diaspora) diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py index 4780e516601532..69ab59153d377c 100644 --- a/homeassistant/components/jewish_calendar/const.py +++ b/homeassistant/components/jewish_calendar/const.py @@ -1,7 +1,6 @@ """Jewish Calendar constants.""" from enum import StrEnum -from typing import TYPE_CHECKING, Self DOMAIN = "jewish_calendar" @@ -24,65 +23,21 @@ class DailyCalendarEventType(StrEnum): - """Daily Calendar event types with metadata.""" + """Daily Calendar event types.""" DATE = "date" - ALOT_HASHACHAR = ( - "alot_hashachar", - "Alot Hashachar", # codespell:ignore alot - "Halachic dawn", - ) - NETZ_HACHAMA = ("netz_hachama", "Netz Hachama", "Halachic sunrise") - SOF_ZMAN_SHEMA_GRA = ( - "sof_zman_shema_gra", - 'Sof Zman Shema (Gr"A)', # codespell:ignore shema - "Latest time for Shema", # codespell:ignore shema - ) - SOF_ZMAN_SHEMA_MGA = ( - "sof_zman_shema_mga", - 'Sof Zman Shema (Mg"A)', # codespell:ignore shema - "Latest time for Shema", # codespell:ignore shema - ) - SOF_ZMAN_TFILLA_GRA = ( - "sof_zman_tfilla_gra", - 'Sof Zman Tefilla (Gr"A)', - "Latest time for Tefilla", - ) - SOF_ZMAN_TFILLA_MGA = ( - "sof_zman_tfilla_mga", - 'Sof Zman Tefilla (Mg"A)', - "Latest time for Tefilla", - ) - CHATZOT_HAYOM = ("chatzot_hayom", "Chatzot Hayom", "Halachic midday") - MINCHA_GEDOLA = ("mincha_gedola", "Mincha Gedola", "Earliest time for Mincha") - MINCHA_KETANA = ("mincha_ketana", "Mincha Ketana", "Preferable time for Mincha") - PLAG_HAMINCHA = ("plag_hamincha", "Plag Hamincha", "Plag Hamincha") - SHKIA = ("shkia", "Shkia", "Sunset") - TSET_HAKOHAVIM = ("tset_hakohavim_tsom", "T'set Hakochavim", "Nightfall") - - if TYPE_CHECKING: - _summary: str - _description_prefix: str - - def __new__( - cls, value: str, summary: str = "", description_prefix: str = "" - ) -> Self: - """Create new enum member with additional attributes.""" - obj = str.__new__(cls, value) - obj._value_ = value - obj._summary = summary # noqa: SLF001 - obj._description_prefix = description_prefix # noqa: SLF001 - return obj - - @property - def summary(self) -> str: - """Return the summary for the event.""" - return self._summary - - @property - def description_prefix(self) -> str: - """Return the description prefix for the event.""" - return self._description_prefix + ALOT_HASHACHAR = "alot_hashachar" + NETZ_HACHAMA = "netz_hachama" + SOF_ZMAN_SHEMA_GRA = "sof_zman_shema_gra" + SOF_ZMAN_SHEMA_MGA = "sof_zman_shema_mga" + SOF_ZMAN_TFILLA_GRA = "sof_zman_tfilla_gra" + SOF_ZMAN_TFILLA_MGA = "sof_zman_tfilla_mga" + CHATZOT_HAYOM = "chatzot_hayom" + MINCHA_GEDOLA = "mincha_gedola" + MINCHA_KETANA = "mincha_ketana" + PLAG_HAMINCHA = "plag_hamincha" + SHKIA = "shkia" + TSET_HAKOHAVIM = "tset_hakohavim_tsom" class YearlyCalendarEventType(StrEnum): diff --git a/homeassistant/components/jewish_calendar/strings.json b/homeassistant/components/jewish_calendar/strings.json index ff1ee29d3c80da..e2a5cb7b2e5323 100644 --- a/homeassistant/components/jewish_calendar/strings.json +++ b/homeassistant/components/jewish_calendar/strings.json @@ -2,7 +2,7 @@ "common": { "descr_diaspora": "Is the location outside of Israel?", "descr_elevation": "Elevation in meters above sea level. This is used to calculate the times correctly.", - "descr_language": "Language to use when displaying values in the UI. This does not affect the Hebrew date.", + "descr_language": "Language to use when displaying values in the UI.", "descr_location": "Location to use for the Jewish calendar calculations. By default, the location is set to the Home Assistant location.", "descr_time_zone": "If you specify a location, make sure to specify the time zone for correct calendar times calculations", "diaspora": "Outside of Israel?", diff --git a/homeassistant/components/knx/manifest.json b/homeassistant/components/knx/manifest.json index 7636af14af77b1..11d9cab56322b0 100644 --- a/homeassistant/components/knx/manifest.json +++ b/homeassistant/components/knx/manifest.json @@ -13,7 +13,7 @@ "requirements": [ "xknx==3.20.0", "xknxproject==3.10.0", - "knx-frontend==2026.8.28.62336", + "knx-frontend==2026.9.4.63549", "knx-telegram-store[sqlite,postgres]==0.11.2" ], "single_config_entry": true diff --git a/homeassistant/components/motioneye/config_flow.py b/homeassistant/components/motioneye/config_flow.py index 557025a58994e8..b80df4b75365f1 100644 --- a/homeassistant/components/motioneye/config_flow.py +++ b/homeassistant/components/motioneye/config_flow.py @@ -71,7 +71,7 @@ def _get_form( **url_schema, vol.Optional( CONF_ADMIN_USERNAME, - default=user_input.get(CONF_ADMIN_USERNAME), + default=user_input.get(CONF_ADMIN_USERNAME, ""), ): str, vol.Optional( CONF_ADMIN_PASSWORD, @@ -79,7 +79,7 @@ def _get_form( ): str, vol.Optional( CONF_SURVEILLANCE_USERNAME, - default=user_input.get(CONF_SURVEILLANCE_USERNAME), + default=user_input.get(CONF_SURVEILLANCE_USERNAME, ""), ): str, vol.Optional( CONF_SURVEILLANCE_PASSWORD, diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index f4b11c1c79dcad..42866fa88dbe6e 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -9,5 +9,5 @@ "iot_class": "cloud_polling", "loggers": ["opower"], "quality_scale": "platinum", - "requirements": ["opower==0.20.0"] + "requirements": ["opower==0.21.0"] } diff --git a/homeassistant/components/samsung_exlink/manifest.json b/homeassistant/components/samsung_exlink/manifest.json index e0848ed2b2e3ad..68bde4cfefee9d 100644 --- a/homeassistant/components/samsung_exlink/manifest.json +++ b/homeassistant/components/samsung_exlink/manifest.json @@ -9,5 +9,5 @@ "iot_class": "local_polling", "loggers": ["samsung_exlink"], "quality_scale": "silver", - "requirements": ["samsung-exlink==1.1.0"] + "requirements": ["samsung-exlink==1.1.1"] } diff --git a/homeassistant/components/serial/manifest.json b/homeassistant/components/serial/manifest.json index 9b55c513194d7a..97a3a1b48f3671 100644 --- a/homeassistant/components/serial/manifest.json +++ b/homeassistant/components/serial/manifest.json @@ -4,5 +4,5 @@ "codeowners": ["@fabaff"], "documentation": "https://www.home-assistant.io/integrations/serial", "iot_class": "local_polling", - "requirements": ["serialx==1.9.0"] + "requirements": ["serialx==1.10.0"] } diff --git a/homeassistant/components/sofar/manifest.json b/homeassistant/components/sofar/manifest.json index 51e28d8743911a..3fd7fd2ae37943 100644 --- a/homeassistant/components/sofar/manifest.json +++ b/homeassistant/components/sofar/manifest.json @@ -8,5 +8,5 @@ "integration_type": "device", "iot_class": "local_polling", "quality_scale": "silver", - "requirements": ["sofar-modbus==0.9.0"] + "requirements": ["sofar-modbus==0.9.1"] } diff --git a/homeassistant/components/synology_dsm/config_flow.py b/homeassistant/components/synology_dsm/config_flow.py index 07ed847bdc1c5b..2654d74fdd5fe4 100644 --- a/homeassistant/components/synology_dsm/config_flow.py +++ b/homeassistant/components/synology_dsm/config_flow.py @@ -237,7 +237,8 @@ async def async_validate_input_create_entry( self.shares = await self.api.file.get_shared_folders(only_writable=True) if self.shares and not backup_path: - return await self.async_step_backup_share(user_input) + self.saved_user_input = user_input + return await self.async_step_backup_share() # unique_id should be serial for services purpose existing_entry = await self.async_set_unique_id(serial, raise_on_progress=False) @@ -409,16 +410,13 @@ async def async_step_2sa( return await self.async_step_user(user_input) async def async_step_backup_share( - self, user_input: dict[str, Any], errors: dict[str, str] | None = None + self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Select backup location.""" if TYPE_CHECKING: assert self.shares is not None - if not self.saved_user_input: - self.saved_user_input = user_input - - if CONF_BACKUP_PATH not in user_input and CONF_BACKUP_SHARE not in user_input: + if user_input is None: return self.async_show_form( step_id="backup_share", data_schema=vol.Schema( @@ -435,15 +433,15 @@ async def async_step_backup_share( vol.Required( CONF_BACKUP_PATH, default=f"{DEFAULT_BACKUP_PATH}_{slugify(self.hass.config.location_name)}", - ): str, + ): vol.All(str, vol.Length(min=1)), } ), ) - user_input = {**self.saved_user_input, **user_input} - self.saved_user_input = {} - - return await self.async_step_user(user_input) + # The credentials stay available, so a retry after a failure still has them + return await self.async_validate_input_create_entry( + {**self.saved_user_input, **user_input}, step_id="user" + ) def _async_get_existing_entry(self, discovered_mac: str) -> ConfigEntry | None: """See if we already have a configured NAS with this MAC address.""" diff --git a/homeassistant/components/tradfri/entity.py b/homeassistant/components/tradfri/entity.py index 9792d77a514d23..306e361743d093 100644 --- a/homeassistant/components/tradfri/entity.py +++ b/homeassistant/components/tradfri/entity.py @@ -6,6 +6,7 @@ from typing import Any, cast, override from pytradfri.command import Command +from pytradfri.const import ATTR_DEVICE_FIRMWARE_VERSION from pytradfri.device import Device from pytradfri.error import RequestError @@ -61,7 +62,7 @@ def __init__( manufacturer=info.manufacturer, model=info.model_number, name=self._device.name, - sw_version=info.firmware_version, + sw_version=info.raw.get(ATTR_DEVICE_FIRMWARE_VERSION), via_device_id=dr.async_get_device_id_by_identifier( device_coordinator.hass, (DOMAIN, gateway_id), diff --git a/homeassistant/components/usb/manifest.json b/homeassistant/components/usb/manifest.json index 205913f357f8f0..0c39ab2d7e1989 100644 --- a/homeassistant/components/usb/manifest.json +++ b/homeassistant/components/usb/manifest.json @@ -8,5 +8,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["aiousbwatcher==1.1.2", "serialx==1.9.0"] + "requirements": ["aiousbwatcher==1.1.2", "serialx==1.10.0"] } diff --git a/homeassistant/components/weheat/diagnostics.py b/homeassistant/components/weheat/diagnostics.py new file mode 100644 index 00000000000000..ea119c86bae21c --- /dev/null +++ b/homeassistant/components/weheat/diagnostics.py @@ -0,0 +1,30 @@ +"""Diagnostics support for Weheat.""" + +from typing import Any + +from homeassistant.components.diagnostics import async_redact_data +from homeassistant.core import HomeAssistant + +from .coordinator import WeheatConfigEntry + +TO_REDACT = {"heat_pump_id", "uuid", "sn"} + + +async def async_get_config_entry_diagnostics( + hass: HomeAssistant, entry: WeheatConfigEntry +) -> dict[str, Any]: + """Return diagnostics for a config entry.""" + return { + "heat_pumps": [ + { + "info": async_redact_data(vars(weheatdata.heat_pump_info), TO_REDACT), + "logs": async_redact_data( + weheatdata.data_coordinator.data.raw_content or {}, TO_REDACT + ), + "energy": async_redact_data( + weheatdata.energy_coordinator.data.raw_content or {}, TO_REDACT + ), + } + for weheatdata in entry.runtime_data + ] + } diff --git a/homeassistant/components/weheat/icons.json b/homeassistant/components/weheat/icons.json index 9606cbdf6fba3c..0b20377a9ed9df 100644 --- a/homeassistant/components/weheat/icons.json +++ b/homeassistant/components/weheat/icons.json @@ -33,6 +33,9 @@ "cop": { "default": "mdi:speedometer" }, + "dhw_control_method": { + "default": "mdi:tune" + }, "dhw_flow_volume": { "default": "mdi:pump" }, @@ -51,6 +54,9 @@ "electricity_used_heating": { "default": "mdi:flash" }, + "electricity_used_indoor_unit": { + "default": "mdi:flash" + }, "energy_output": { "default": "mdi:flash" }, diff --git a/homeassistant/components/weheat/quality_scale.yaml b/homeassistant/components/weheat/quality_scale.yaml index 2d4e4b892543be..0f0710576533a5 100644 --- a/homeassistant/components/weheat/quality_scale.yaml +++ b/homeassistant/components/weheat/quality_scale.yaml @@ -54,7 +54,7 @@ rules: # Gold devices: done - diagnostics: todo + diagnostics: done discovery-update-info: status: exempt comment: | @@ -74,7 +74,7 @@ rules: status: todo comment: | While unlikely to happen. Check if it is easily integrated. - entity-category: todo + entity-category: done entity-device-class: done entity-disabled-by-default: todo entity-translations: done diff --git a/homeassistant/components/weheat/sensor.py b/homeassistant/components/weheat/sensor.py index 421de67e658d65..c56ab44b068881 100644 --- a/homeassistant/components/weheat/sensor.py +++ b/homeassistant/components/weheat/sensor.py @@ -15,6 +15,7 @@ from homeassistant.const import ( PERCENTAGE, REVOLUTIONS_PER_MINUTE, + EntityCategory, UnitOfEnergy, UnitOfPower, UnitOfTemperature, @@ -111,6 +112,15 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, value_fn=lambda status: status.air_inlet_temperature, ), + WeHeatSensorEntityDescription( + translation_key="air_outlet_temperature", + key="air_outlet_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + value_fn=lambda status: status.air_outlet_temperature, + ), WeHeatSensorEntityDescription( translation_key="thermostat_water_setpoint", key="thermostat_water_setpoint", @@ -203,6 +213,30 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR, value_fn=lambda status: status.dhw_flow_volume, ), + WeHeatSensorEntityDescription( + translation_key="dhw_target_temperature", + key="dhw_target_temperature", + native_unit_of_measurement=UnitOfTemperature.CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, + state_class=SensorStateClass.MEASUREMENT, + suggested_display_precision=DISPLAY_PRECISION_WATER_TEMP, + # A target of zero is how the heat pump says DHW control is off. + value_fn=lambda status: status.dhw_target_temperature or None, + ), + WeHeatSensorEntityDescription( + translation_key="dhw_control_method", + key="dhw_control_method", + device_class=SensorDeviceClass.ENUM, + entity_category=EntityCategory.DIAGNOSTIC, + options=[method.name.lower() for method in HeatPump.DhwControlMethod], + value_fn=( + lambda status: ( + status.dhw_control_method.name.lower() + if status.dhw_control_method is not None + else None + ) + ), + ), ] ENERGY_SENSORS = [ @@ -214,6 +248,14 @@ class WeHeatSensorEntityDescription(SensorEntityDescription): state_class=SensorStateClass.TOTAL_INCREASING, value_fn=lambda status: status.energy_total, ), + WeHeatSensorEntityDescription( + translation_key="electricity_used_indoor_unit", + key="electricity_used_indoor_unit", + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda status: status.energy_in_indoor_unit, + ), WeHeatSensorEntityDescription( translation_key="energy_output", key="energy_output", @@ -326,8 +368,6 @@ async def async_setup_entry( entity_description, ) for entity_description in DHW_SENSORS - if entity_description.value_fn(weheatdata.data_coordinator.data) - is not None ) entities.extend( WeheatHeatPumpSensor( @@ -336,8 +376,6 @@ async def async_setup_entry( entity_description, ) for entity_description in DHW_ENERGY_SENSORS - if entity_description.value_fn(weheatdata.energy_coordinator.data) - is not None ) entities.extend( WeheatHeatPumpSensor( diff --git a/homeassistant/components/weheat/strings.json b/homeassistant/components/weheat/strings.json index b84c19ea58bb86..2f1fd03cca79e8 100644 --- a/homeassistant/components/weheat/strings.json +++ b/homeassistant/components/weheat/strings.json @@ -48,6 +48,9 @@ } }, "sensor": { + "air_outlet_temperature": { + "name": "Air outlet temperature" + }, "central_heating_flow_volume": { "name": "Central heating pump flow" }, @@ -66,9 +69,22 @@ "dhw_bottom_temperature": { "name": "DHW bottom temperature" }, + "dhw_control_method": { + "name": "DHW control method", + "state": { + "boost": "Boost", + "fixed": "Fixed setpoint", + "none": "None", + "schedule": "Schedule", + "weheat_intelligence": "Weheat Intelligence" + } + }, "dhw_flow_volume": { "name": "DHW pump flow" }, + "dhw_target_temperature": { + "name": "DHW target temperature" + }, "dhw_top_temperature": { "name": "DHW top temperature" }, @@ -87,6 +103,9 @@ "electricity_used_heating": { "name": "Electricity used heating" }, + "electricity_used_indoor_unit": { + "name": "Electricity used indoor unit" + }, "electricity_used_standby": { "name": "Electricity used standby" }, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 66456791d4a892..82760573232854 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -65,14 +65,14 @@ PyTurboJPEG==1.8.3 PyYAML==6.0.3 requests==2.34.2 securetar==2026.4.1 -serialx==1.9.0 +serialx==1.10.0 SQLAlchemy==2.0.52 standard-aifc==3.13.0 standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.6 +uv==0.12.7 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.1 diff --git a/pyproject.toml b/pyproject.toml index 95561ae862dffe..e6748a28cbde0d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -77,7 +77,7 @@ dependencies = [ "typing-extensions>=4.16.0,<5.0", "ulid-transform==2.2.9", "urllib3>=2.0", - "uv==0.12.6", + "uv==0.12.7", "probatio==0.11.4", "yarl==1.24.5", "webrtc-models==0.3.0", @@ -641,7 +641,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.16.4" +required-version = ">=0.16.5" [tool.ruff.lint] select = [ diff --git a/requirements.txt b/requirements.txt index 88cee4233102d1..f2435651941305 100644 --- a/requirements.txt +++ b/requirements.txt @@ -57,7 +57,7 @@ standard-telnetlib==3.13.0 typing-extensions>=4.16.0,<5.0 ulid-transform==2.2.9 urllib3>=2.0 -uv==0.12.6 +uv==0.12.7 webrtc-models==0.3.0 yarl==1.24.5 zeroconf==0.151.1 diff --git a/requirements_all.txt b/requirements_all.txt index 16f6c64f44faad..5c49713501a48a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -956,7 +956,7 @@ enocean-async==0.4.2 enturclient==0.2.4 # homeassistant.components.environment_canada -env-canada==0.19.0 +env-canada==0.19.2 # homeassistant.components.season ephem==4.1.6 @@ -1466,7 +1466,7 @@ kiwiki-client==0.1.2 knocki==0.4.2 # homeassistant.components.knx -knx-frontend==2026.8.28.62336 +knx-frontend==2026.9.4.63549 # homeassistant.components.knx knx-telegram-store[sqlite,postgres]==0.11.2 @@ -1839,7 +1839,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.3 # homeassistant.components.opower -opower==0.20.0 +opower==0.21.0 # homeassistant.components.oralb oralb-ble==1.1.3 @@ -2735,7 +2735,7 @@ python-homeassistant-analytics==0.9.0 python-homewizard-energy==10.2.0 # homeassistant.components.hotspring -python-hotspring==2.0.1 +python-hotspring==2.1.0 # homeassistant.components.hp_ilo python-hpilo==4.4.3 @@ -3013,7 +3013,7 @@ ruuvitag-ble==0.4.0 rxv==0.7.0 # homeassistant.components.samsung_exlink -samsung-exlink==1.1.0 +samsung-exlink==1.1.1 # homeassistant.components.samsungtv samsungctl[websocket]==0.7.1 @@ -3066,7 +3066,7 @@ sentry-sdk==2.48.0 # homeassistant.components.serial # homeassistant.components.usb -serialx==1.9.0 +serialx==1.10.0 # homeassistant.components.sfr_box sfrbox-api==0.1.1 @@ -3114,7 +3114,7 @@ snapcast==2.3.8 soco==0.31.2 # homeassistant.components.sofar -sofar-modbus==0.9.0 +sofar-modbus==0.9.1 # homeassistant.components.solaredge_local solaredge-local==0.2.3 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index f72cd3fcd8754a..dd2c429e2960e8 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,6 +1,6 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.3 -ruff==0.16.4 +ruff==0.16.5 yamllint==1.38.0 zizmor==1.29.0 diff --git a/tests/components/hotspring/test_config_flow.py b/tests/components/hotspring/test_config_flow.py index 677da008b306d9..18e620ba849dc6 100644 --- a/tests/components/hotspring/test_config_flow.py +++ b/tests/components/hotspring/test_config_flow.py @@ -4,7 +4,12 @@ from ipaddress import ip_address from unittest.mock import MagicMock -from hotspring import HotSpringConnectionError, HotSpringError, Spa +from hotspring import ( + HotSpringConnectionError, + HotSpringError, + HotSpringSNADetectedError, + Spa, +) import pytest from homeassistant.components.hotspring.const import DOMAIN @@ -69,14 +74,21 @@ async def test_user_device_exists_abort( @pytest.mark.parametrize( - "exception", - [HotSpringConnectionError, HotSpringError], + ("exception", "error_key"), + [ + (HotSpringConnectionError, "cannot_connect"), + (HotSpringError, "cannot_connect"), + (HotSpringSNADetectedError, "sna_device"), + ], ) @pytest.mark.usefixtures("mock_setup_entry") -async def test_form_cannot_connect( - hass: HomeAssistant, mock_hotspring: MagicMock, exception: type[Exception] +async def test_form_errors( + hass: HomeAssistant, + mock_hotspring: MagicMock, + exception: type[Exception], + error_key: str, ) -> None: - """Test we show user form on Hot Spring connection error and recover.""" + """Test we show user form on error and recover.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": SOURCE_USER}, @@ -92,7 +104,7 @@ async def test_form_cannot_connect( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" - assert result["errors"] == {"base": "cannot_connect"} + assert result["errors"] == {"base": error_key} mock_hotspring.update.side_effect = None result = await hass.config_entries.flow.async_configure( @@ -158,13 +170,20 @@ async def test_full_zeroconf_flow_implementation(hass: HomeAssistant) -> None: @pytest.mark.parametrize( - "exception", - [HotSpringConnectionError, HotSpringError], + ("exception", "reason"), + [ + (HotSpringConnectionError, "cannot_connect"), + (HotSpringError, "cannot_connect"), + (HotSpringSNADetectedError, "sna_device"), + ], ) -async def test_zeroconf_connection_error( - hass: HomeAssistant, mock_hotspring: MagicMock, exception: type[Exception] +async def test_zeroconf_error( + hass: HomeAssistant, + mock_hotspring: MagicMock, + exception: type[Exception], + reason: str, ) -> None: - """Test we abort zeroconf flow on Hot Spring connection error.""" + """Test we abort zeroconf flow on Hot Spring error.""" mock_hotspring.update.side_effect = exception result = await hass.config_entries.flow.async_init( @@ -174,7 +193,7 @@ async def test_zeroconf_connection_error( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "cannot_connect" + assert result["reason"] == reason @pytest.mark.usefixtures("mock_hotspring") diff --git a/tests/components/imou/const.py b/tests/components/imou/const.py index 61ad42ee5b9528..d9b27a1d5eba3c 100644 --- a/tests/components/imou/const.py +++ b/tests/components/imou/const.py @@ -2,6 +2,8 @@ from pyimouapi.const import ( PARAM_BATTERY, + PARAM_COLLECTION_POINT, + PARAM_COLLECTION_POINT_PROMPT, PARAM_CURRENT_OPTION, PARAM_DEVICE_VOLUME, PARAM_MOTION_DETECT, @@ -63,6 +65,14 @@ PARAM_CURRENT_OPTION: "medium", PARAM_OPTIONS: ["mute", "low", "medium", "high"], }, + PARAM_COLLECTION_POINT: { + PARAM_CURRENT_OPTION: PARAM_COLLECTION_POINT_PROMPT, + PARAM_OPTIONS: [ + PARAM_COLLECTION_POINT_PROMPT, + "Front door", + "Back yard", + ], + }, } DEFAULT_SWITCHES = { diff --git a/tests/components/imou/snapshots/test_select.ambr b/tests/components/imou/snapshots/test_select.ambr index 2f33b5572814d9..d547ae59a99812 100644 --- a/tests/components/imou/snapshots/test_select.ambr +++ b/tests/components/imou/snapshots/test_select.ambr @@ -1,4 +1,65 @@ # serializer version: 1 +# name: test_select_entities_snapshot[select_mock_devices-platforms0][select.device_1_go_to_collection_point-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'select_collection_point', + 'Front door', + 'Back yard', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'select', + 'entity_category': None, + 'entity_id': 'select.device_1_go_to_collection_point', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Go to collection point', + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Go to collection point', + 'platform': 'imou', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'collection_point', + 'unique_id': 'd1$collection_point', + 'unit_of_measurement': None, + }) +# --- +# name: test_select_entities_snapshot[select_mock_devices-platforms0][select.device_1_go_to_collection_point-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'Device 1 Go to collection point', + : list([ + 'select_collection_point', + 'Front door', + 'Back yard', + ]), + }), + 'context': , + 'entity_id': 'select.device_1_go_to_collection_point', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'select_collection_point', + }) +# --- # name: test_select_entities_snapshot[select_mock_devices-platforms0][select.device_1_night_vision_mode-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/imou/test_select.py b/tests/components/imou/test_select.py index 25cc64c275671a..922f5d1008cc5e 100644 --- a/tests/components/imou/test_select.py +++ b/tests/components/imou/test_select.py @@ -4,6 +4,8 @@ from freezegun.api import FrozenDateTimeFactory from pyimouapi.const import ( + PARAM_COLLECTION_POINT, + PARAM_COLLECTION_POINT_PROMPT, PARAM_CURRENT_OPTION, PARAM_DEVICE_VOLUME, PARAM_NIGHT_VISION_MODE, @@ -88,40 +90,59 @@ async def test_setup_ignores_unknown_select_types( @pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) @pytest.mark.parametrize("imou_mock_devices", [select_mock_devices], indirect=True) +@pytest.mark.parametrize( + ("unique_id", "option", "select_type", "expected_state"), + [ + pytest.param( + "d1$device_volume", "high", PARAM_DEVICE_VOLUME, "high", id="volume" + ), + pytest.param( + f"d1${PARAM_COLLECTION_POINT}", + "Front door", + PARAM_COLLECTION_POINT, + PARAM_COLLECTION_POINT_PROMPT, + id="collection_point", + ), + ], +) @pytest.mark.usefixtures("init_integration") async def test_select_option_via_domain_service( hass: HomeAssistant, entity_registry: er.EntityRegistry, mock_config_entry: MockConfigEntry, mock_imou_ha_device_manager: MagicMock, + unique_id: str, + option: str, + select_type: str, + expected_state: str, ) -> None: """Selecting an option calls the vendor library through the coordinator.""" async def _side_effect(device: ImouHaDevice, select_type: str, option: str) -> None: - device.selects[select_type][PARAM_CURRENT_OPTION] = option + device.selects[select_type][PARAM_CURRENT_OPTION] = expected_state mock_imou_ha_device_manager.async_select_option.side_effect = _side_effect - volume_entry = next( + select_entry = next( entry for entry in er.async_entries_for_config_entry( entity_registry, mock_config_entry.entry_id ) - if entry.unique_id == "d1$device_volume" + if entry.unique_id == unique_id ) await hass.services.async_call( SELECT_DOMAIN, SERVICE_SELECT_OPTION, - {ATTR_ENTITY_ID: volume_entry.entity_id, ATTR_OPTION: "high"}, + {ATTR_ENTITY_ID: select_entry.entity_id, ATTR_OPTION: option}, blocking=True, ) mock_imou_ha_device_manager.async_select_option.assert_awaited_once() call = mock_imou_ha_device_manager.async_select_option.await_args assert call is not None - assert call.args[1] == PARAM_DEVICE_VOLUME - assert call.args[2] == "high" - assert hass.states.get(volume_entry.entity_id).state == "high" + assert call.args[1] == select_type + assert call.args[2] == option + assert hass.states.get(select_entry.entity_id).state == expected_state @pytest.mark.parametrize("platforms", [[Platform.SELECT]], indirect=True) diff --git a/tests/components/jewish_calendar/__init__.py b/tests/components/jewish_calendar/__init__.py index f69d6c3657ed05..a0a171ec531a58 100644 --- a/tests/components/jewish_calendar/__init__.py +++ b/tests/components/jewish_calendar/__init__.py @@ -2,6 +2,22 @@ from dataclasses import dataclass import datetime as dt +from typing import Protocol + +from homeassistant.core import HomeAssistant + + +class GetCalendarEvents(Protocol): + """Return the events of a calendar entity within a date range.""" + + async def __call__( + self, + hass: HomeAssistant, + entity_id: str, + start_date: dt.datetime, + end_date: dt.datetime | None = None, + ) -> list[dict[str, str]]: + """Return the events between the two dates.""" @dataclass(frozen=True) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 4bc6a6e1921ac1..cbdd445a77848a 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -30,7 +30,7 @@ from homeassistant.core import HomeAssistant from homeassistant.util import dt as dt_util -from . import TimeValue +from . import GetCalendarEvents, TimeValue from tests.common import MockConfigEntry, async_fire_time_changed @@ -209,7 +209,7 @@ async def setup(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: @pytest.fixture -def get_calendar_events(): +def get_calendar_events() -> GetCalendarEvents: """Fixture that returns a function to get calendar events for a date range.""" async def _get_events( diff --git a/tests/components/jewish_calendar/snapshots/test_calendar.ambr b/tests/components/jewish_calendar/snapshots/test_calendar.ambr index 88aa9c3a7fb16d..d7d0de9acd5664 100644 --- a/tests/components/jewish_calendar/snapshots/test_calendar.ambr +++ b/tests/components/jewish_calendar/snapshots/test_calendar.ambr @@ -12,19 +12,19 @@ # name: test_daily_events[Jerusalem] list([ dict({ - 'description': 'Hebrew date: ה\' שבט ה\' תשפ"ד', + 'description': "Hebrew date: 5 Sh'vat 5784", 'end': '2024-01-16', 'start': '2024-01-15', - 'summary': 'ה\' שבט ה\' תשפ"ד', + 'summary': "5 Sh'vat 5784", }), dict({ - 'description': 'Halachic sunrise: 06:40', + 'description': 'Netz Hachama: 06:40', 'end': '2024-01-15T04:40:00+00:00', 'start': '2024-01-15T04:40:00+00:00', 'summary': 'Netz Hachama', }), dict({ - 'description': 'Sunset: 16:57', + 'description': 'Shkia: 16:57', 'end': '2024-01-15T14:57:00+00:00', 'start': '2024-01-15T14:57:00+00:00', 'summary': 'Shkia', @@ -33,26 +33,26 @@ 'description': 'Nightfall: 17:26', 'end': '2024-01-15T15:26:00+00:00', 'start': '2024-01-15T15:26:00+00:00', - 'summary': "T'set Hakochavim", + 'summary': 'End of fast', }), ]) # --- # name: test_daily_events[New York] list([ dict({ - 'description': 'Hebrew date: ה\' שבט ה\' תשפ"ד', + 'description': "Hebrew date: 5 Sh'vat 5784", 'end': '2024-01-16', 'start': '2024-01-15', - 'summary': 'ה\' שבט ה\' תשפ"ד', + 'summary': "5 Sh'vat 5784", }), dict({ - 'description': 'Halachic sunrise: 07:18', + 'description': 'Netz Hachama: 07:18', 'end': '2024-01-15T12:18:00+00:00', 'start': '2024-01-15T12:18:00+00:00', 'summary': 'Netz Hachama', }), dict({ - 'description': 'Sunset: 16:53', + 'description': 'Shkia: 16:53', 'end': '2024-01-15T21:53:00+00:00', 'start': '2024-01-15T21:53:00+00:00', 'summary': 'Shkia', @@ -61,7 +61,7 @@ 'description': 'Nightfall: 17:26', 'end': '2024-01-15T22:26:00+00:00', 'start': '2024-01-15T22:26:00+00:00', - 'summary': "T'set Hakochavim", + 'summary': 'End of fast', }), ]) # --- @@ -415,42 +415,42 @@ list([ dict({ 'description': ''' - Jewish Holiday: שושן פורים - Holiday Type: חג (מלאכה מותרת) + Jewish Holiday: Shushan Purim + Holiday type: Work-permitted holiday ''', 'end': '2024-03-26', 'start': '2024-03-25', - 'summary': 'שושן פורים', + 'summary': 'Shushan Purim', }), ]) # --- # name: test_learning_schedule_events[Jerusalem] list([ dict({ - 'description': 'Daf Yomi: בבא בתרא כ', + 'description': 'Daf Yomi: Bava Basra 20', 'end': '2024-07-16', 'start': '2024-07-15', - 'summary': 'בבא בתרא כ', + 'summary': 'Bava Basra 20', }), ]) # --- # name: test_omer_count[calendar_events0-New York] list([ dict({ - 'description': 'Sefirat HaOmer: היום יום אחד לעומר', + 'description': 'Sefirat HaOmer: Today is the first day of the Omer', 'end': '2024-04-25', 'start': '2024-04-24', - 'summary': "א' לעומר", + 'summary': '1 of the Omer', }), ]) # --- # name: test_weekly_portion_on_shabbat[Jerusalem] list([ dict({ - 'description': 'Parshat Hashavua: וארא', + 'description': 'Parshat Hashavua: Vaera', 'end': '2024-01-14', 'start': '2024-01-13', - 'summary': 'וארא', + 'summary': 'Vaera', }), dict({ 'description': 'Havdalah time: 17:35', @@ -463,10 +463,10 @@ # name: test_weekly_portion_on_shabbat[New York] list([ dict({ - 'description': 'Parshat Hashavua: וארא', + 'description': 'Parshat Hashavua: Vaera', 'end': '2024-01-14', 'start': '2024-01-13', - 'summary': 'וארא', + 'summary': 'Vaera', }), dict({ 'description': 'Havdalah time: 17:35', @@ -480,18 +480,18 @@ list([ dict({ 'description': ''' - Jewish Holiday: שמחת תורה - Holiday Type: יום טוב + Jewish Holiday: Simchat Torah + Holiday type: Yom Tov ''', 'end': '2024-10-26', 'start': '2024-10-25', - 'summary': 'שמחת תורה', + 'summary': 'Simchat Torah', }), dict({ - 'description': 'Parshat Hashavua: וזאת הברכה', + 'description': 'Parshat Hashavua: Vezot Habracha', 'end': '2024-10-26', 'start': '2024-10-25', - 'summary': 'וזאת הברכה', + 'summary': 'Vezot Habracha', }), dict({ 'description': 'Candle lighting time: 17:42', @@ -505,27 +505,27 @@ list([ dict({ 'description': ''' - Jewish Holiday: שמיני עצרת - Holiday Type: יום טוב + Jewish Holiday: Shmini Atzeret + Holiday type: Yom Tov ''', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'שמיני עצרת', + 'summary': 'Shmini Atzeret', }), dict({ 'description': ''' - Jewish Holiday: שמחת תורה - Holiday Type: יום טוב + Jewish Holiday: Simchat Torah + Holiday type: Yom Tov ''', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'שמחת תורה', + 'summary': 'Simchat Torah', }), dict({ - 'description': 'Parshat Hashavua: וזאת הברכה', + 'description': 'Parshat Hashavua: Vezot Habracha', 'end': '2024-10-25', 'start': '2024-10-24', - 'summary': 'וזאת הברכה', + 'summary': 'Vezot Habracha', }), dict({ 'description': 'Havdalah time: 18:34', diff --git a/tests/components/jewish_calendar/test_calendar.py b/tests/components/jewish_calendar/test_calendar.py index 035338faf3b8b5..89e8e2c3e62ee7 100644 --- a/tests/components/jewish_calendar/test_calendar.py +++ b/tests/components/jewish_calendar/test_calendar.py @@ -30,6 +30,8 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from . import GetCalendarEvents + from tests.common import MockConfigEntry, snapshot_platform # Entity IDs for the three calendars @@ -113,6 +115,90 @@ async def test_timed_event_format(hass: HomeAssistant, get_calendar_events) -> N ) +@pytest.mark.freeze_time("2024-01-15 12:00:00") +@pytest.mark.parametrize("location_data", ["Jerusalem"], indirect=True) +@pytest.mark.parametrize( + ( + "calendar_events", + "entity_id", + "query_date", + "language", + "summary", + "description", + ), + [ + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "en", + "Shkia", + "Shkia: 16:57", + id="zman-english", + ), + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "fr", + "Coucher du soleil", + "Coucher du soleil : 16:57", + id="zman-french", + ), + pytest.param( + {CONF_DAILY_EVENTS: [DailyCalendarEventType.SHKIA]}, + DAILY_EVENTS, + dt.datetime(2024, 1, 15), + "he", + "שקיעה", + "שקיעה: 16:57", + id="zman-hebrew", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "en", + "Vaera", + "Parshat Hashavua: Vaera", + id="weekly-portion-english", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "fr", + "Va'era", + "Parashat HaShavoua : Va'era", + id="weekly-portion-french", + ), + pytest.param( + {CONF_YEARLY_EVENTS: [YearlyCalendarEventType.WEEKLY_PORTION]}, + YEARLY_EVENTS, + dt.datetime(2024, 1, 13), + "he", + "וארא", + "פרשת השבוע: וארא", + id="weekly-portion-hebrew", + ), + ], +) +@pytest.mark.usefixtures("setup") +async def test_events_use_configured_language( + hass: HomeAssistant, + get_calendar_events: GetCalendarEvents, + entity_id: str, + query_date: dt.datetime, + summary: str, + description: str, +) -> None: + """Test event text is rendered in the configured language.""" + events = await get_calendar_events(hass, entity_id, query_date) + assert len(events) == 1 + assert events[0]["summary"] == summary + assert events[0]["description"] == description + + # ─── Daily Events ──────────────────────────────────────────────────── # The daily events calendar produces the Hebrew date and configured # halachic times for each day. Times differ by location and timezone. diff --git a/tests/components/motioneye/test_config_flow.py b/tests/components/motioneye/test_config_flow.py index 7dbc851394a3f8..fbd8a0915681e9 100644 --- a/tests/components/motioneye/test_config_flow.py +++ b/tests/components/motioneye/test_config_flow.py @@ -7,6 +7,7 @@ MotionEyeClientInvalidAuthError, MotionEyeClientRequestError, ) +import pytest from homeassistant import config_entries from homeassistant.components.motioneye.const import ( @@ -30,7 +31,44 @@ from tests.common import MockConfigEntry -async def test_user_success(hass: HomeAssistant) -> None: +@pytest.mark.parametrize( + ("user_input", "expected_data"), + [ + pytest.param( + { + CONF_URL: TEST_URL, + CONF_ADMIN_USERNAME: "admin-username", + CONF_ADMIN_PASSWORD: "admin-password", + CONF_SURVEILLANCE_USERNAME: "surveillance-username", + CONF_SURVEILLANCE_PASSWORD: "surveillance-password", + }, + { + CONF_URL: TEST_URL, + CONF_ADMIN_USERNAME: "admin-username", + CONF_ADMIN_PASSWORD: "admin-password", + CONF_SURVEILLANCE_USERNAME: "surveillance-username", + CONF_SURVEILLANCE_PASSWORD: "surveillance-password", + }, + id="credentials", + ), + pytest.param( + {CONF_URL: TEST_URL}, + { + CONF_URL: TEST_URL, + CONF_ADMIN_USERNAME: "", + CONF_ADMIN_PASSWORD: "", + CONF_SURVEILLANCE_USERNAME: "", + CONF_SURVEILLANCE_PASSWORD: "", + }, + id="no_credentials", + ), + ], +) +async def test_user_success( + hass: HomeAssistant, + user_input: dict[str, str], + expected_data: dict[str, str], +) -> None: """Test successful user flow.""" result = await hass.config_entries.flow.async_init( @@ -52,26 +90,13 @@ async def test_user_success(hass: HomeAssistant) -> None: ) as mock_setup_entry, ): result = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - CONF_URL: TEST_URL, - CONF_ADMIN_USERNAME: "admin-username", - CONF_ADMIN_PASSWORD: "admin-password", - CONF_SURVEILLANCE_USERNAME: "surveillance-username", - CONF_SURVEILLANCE_PASSWORD: "surveillance-password", - }, + result["flow_id"], user_input ) await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == f"{TEST_URL}" - assert result["data"] == { - CONF_URL: TEST_URL, - CONF_ADMIN_USERNAME: "admin-username", - CONF_ADMIN_PASSWORD: "admin-password", - CONF_SURVEILLANCE_USERNAME: "surveillance-username", - CONF_SURVEILLANCE_PASSWORD: "surveillance-password", - } + assert result["data"] == expected_data assert len(mock_setup_entry.mock_calls) == 1 assert mock_client.async_client_close.called diff --git a/tests/components/synology_dsm/test_config_flow.py b/tests/components/synology_dsm/test_config_flow.py index 7c17d8116af7b1..941fce9f9d644b 100644 --- a/tests/components/synology_dsm/test_config_flow.py +++ b/tests/components/synology_dsm/test_config_flow.py @@ -37,7 +37,7 @@ CONF_VERIFY_SSL, ) from homeassistant.core import HomeAssistant -from homeassistant.data_entry_flow import FlowResultType +from homeassistant.data_entry_flow import FlowResultType, InvalidData from homeassistant.helpers.service_info.ssdp import ( ATTR_UPNP_FRIENDLY_NAME, ATTR_UPNP_SERIAL, @@ -383,6 +383,80 @@ async def test_user_with_filestation( assert result["data"] == snapshot +@pytest.mark.usefixtures("mock_setup_entry") +async def test_backup_share_form_reopened( + hass: HomeAssistant, + service_with_filestation: MagicMock, +) -> None: + """Test the backup location form can be shown again without losing the input.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_VERIFY_SSL: VERIFY_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + # The dialog is closed and reopened before a location is picked + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_BACKUP_SHARE: "/ha_backup", CONF_BACKUP_PATH: "automatic_ha_backups"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["result"].unique_id == SERIAL + + +@pytest.mark.usefixtures("mock_setup_entry") +async def test_backup_share_requires_a_path( + hass: HomeAssistant, + service_with_filestation: MagicMock, +) -> None: + """Test an empty backup path is rejected instead of looping the flow.""" + with patch( + "homeassistant.components.synology_dsm.config_flow.SynologyDSM", + return_value=service_with_filestation, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data={ + CONF_HOST: HOST, + CONF_PORT: PORT, + CONF_SSL: USE_SSL, + CONF_VERIFY_SSL: VERIFY_SSL, + CONF_USERNAME: USERNAME, + CONF_PASSWORD: PASSWORD, + }, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "backup_share" + + with pytest.raises(InvalidData): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_BACKUP_SHARE: "/ha_backup", CONF_BACKUP_PATH: ""}, + ) + + @pytest.mark.usefixtures("mock_setup_entry") async def test_reauth(hass: HomeAssistant, service: MagicMock) -> None: """Test reauthentication.""" diff --git a/tests/components/tradfri/test_sensor.py b/tests/components/tradfri/test_sensor.py index eee1830f066c07..401bf6f52f6e7d 100644 --- a/tests/components/tradfri/test_sensor.py +++ b/tests/components/tradfri/test_sensor.py @@ -1,9 +1,12 @@ """Tradfri sensor platform tests.""" +import json + import pytest from pytradfri.const import ( ATTR_AIR_PURIFIER_AIR_QUALITY, ATTR_DEVICE_BATTERY, + ATTR_DEVICE_FIRMWARE_VERSION, ATTR_DEVICE_INFO, ATTR_REACHABLE_STATE, ROOT_AIR_PURIFIER, @@ -27,7 +30,7 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import GATEWAY_ID from .common import CommandStore, setup_integration @@ -41,6 +44,14 @@ def remote_control() -> str: return load_fixture("remote_control.json", DOMAIN) +@pytest.fixture(scope="module") +def remote_control_without_firmware(remote_control: str) -> str: + """Return a remote control response that reports no firmware version.""" + device = json.loads(remote_control) + del device[ATTR_DEVICE_INFO][ATTR_DEVICE_FIRMWARE_VERSION] + return json.dumps(device) + + @pytest.mark.parametrize("device", ["remote_control"], indirect=True) async def test_battery_sensor( hass: HomeAssistant, @@ -70,6 +81,26 @@ async def test_battery_sensor( assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT +@pytest.mark.parametrize("device", ["remote_control_without_firmware"], indirect=True) +@pytest.mark.usefixtures("device") +async def test_battery_sensor_without_firmware_version( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, +) -> None: + """Test that a device that reports no firmware version still gets its sensor.""" + entry = await setup_integration(hass) + + state = hass.states.get("sensor.test_battery") + assert state + assert state.state == "87" + + device_entry = device_registry.async_get_device_by_identifier( + (DOMAIN, f"{GATEWAY_ID}-65536"), entry.entry_id + ) + assert device_entry + assert device_entry.sw_version is None + + @pytest.mark.parametrize("device", ["blind"], indirect=True) async def test_cover_battery_sensor( hass: HomeAssistant, diff --git a/tests/components/weheat/conftest.py b/tests/components/weheat/conftest.py index a7dc38f3c593ae..cd57febf5a7e6f 100644 --- a/tests/components/weheat/conftest.py +++ b/tests/components/weheat/conftest.py @@ -111,6 +111,7 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.water_outlet_temperature = 22 mock_heat_pump_instance.water_house_in_temperature = 33 mock_heat_pump_instance.air_inlet_temperature = 44 + mock_heat_pump_instance.air_outlet_temperature = 50 mock_heat_pump_instance.power_input = 55 mock_heat_pump_instance.power_output = 66 mock_heat_pump_instance.dhw_top_temperature = 77 @@ -126,6 +127,7 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.energy_in_cooling = 9000 mock_heat_pump_instance.energy_in_standby = 684 mock_heat_pump_instance.energy_total = 28689 + mock_heat_pump_instance.energy_in_indoor_unit = 1042 mock_heat_pump_instance.energy_out_heating = 10000 mock_heat_pump_instance.energy_out_dhw = 6677 mock_heat_pump_instance.energy_out_defrost = -1200 @@ -134,12 +136,20 @@ def mock_weheat_heat_pump_instance() -> MagicMock: mock_heat_pump_instance.compressor_rpm = 4500 mock_heat_pump_instance.compressor_percentage = 100 mock_heat_pump_instance.dhw_flow_volume = 1.12 + mock_heat_pump_instance.dhw_target_temperature = 55 + mock_heat_pump_instance.dhw_control_method = HeatPump.DhwControlMethod.FIXED + mock_heat_pump_instance.dhw_control_method_code = 1 mock_heat_pump_instance.central_heating_flow_volume = 1.23 mock_heat_pump_instance.indoor_unit_water_pump_state = False mock_heat_pump_instance.indoor_unit_auxiliary_pump_state = False mock_heat_pump_instance.indoor_unit_dhw_valve_or_pump_state = None mock_heat_pump_instance.indoor_unit_gas_boiler_state = False mock_heat_pump_instance.indoor_unit_electric_heater_state = True + mock_heat_pump_instance.raw_content = { + "heat_pump_id": TEST_HP_UUID, + "t_water_in": 11, + "total_ein_heating": 12345, + } return mock_heat_pump_instance diff --git a/tests/components/weheat/snapshots/test_diagnostics.ambr b/tests/components/weheat/snapshots/test_diagnostics.ambr new file mode 100644 index 00000000000000..6db4e23f0e93b8 --- /dev/null +++ b/tests/components/weheat/snapshots/test_diagnostics.ambr @@ -0,0 +1,27 @@ +# serializer version: 1 +# name: test_diagnostics + dict({ + 'heat_pumps': list([ + dict({ + 'energy': dict({ + 'heat_pump_id': '**REDACTED**', + 't_water_in': 11, + 'total_ein_heating': 12345, + }), + 'info': dict({ + 'device_name': None, + 'has_ch_boiler': False, + 'has_dhw': True, + 'model': 'Test Model', + 'sn': '**REDACTED**', + 'uuid': '**REDACTED**', + }), + 'logs': dict({ + 'heat_pump_id': '**REDACTED**', + 't_water_in': 11, + 'total_ein_heating': 12345, + }), + }), + ]), + }) +# --- diff --git a/tests/components/weheat/snapshots/test_sensor.ambr b/tests/components/weheat/snapshots/test_sensor.ambr index 61890870abfc6e..201fe11c7db0d1 100644 --- a/tests/components/weheat/snapshots/test_sensor.ambr +++ b/tests/components/weheat/snapshots/test_sensor.ambr @@ -77,6 +77,64 @@ 'state': 'heating', }) # --- +# name: test_all_entities[sensor.test_model_air_outlet_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_air_outlet_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Air outlet temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Air outlet temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'air_outlet_temperature', + 'unique_id': '0000-1111-2222-3333_air_outlet_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_air_outlet_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Test Model Air outlet temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_model_air_outlet_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '50', + }) +# --- # name: test_all_entities[sensor.test_model_central_heating_inlet_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -473,6 +531,72 @@ 'state': '88', }) # --- +# name: test_all_entities[sensor.test_model_dhw_control_method-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : list([ + 'none', + 'fixed', + 'schedule', + 'weheat_intelligence', + 'boost', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_model_dhw_control_method', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DHW control method', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW control method', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_control_method', + 'unique_id': '0000-1111-2222-3333_dhw_control_method', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_control_method-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'enum', + : 'Test Model DHW control method', + : list([ + 'none', + 'fixed', + 'schedule', + 'weheat_intelligence', + 'boost', + ]), + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_control_method', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'fixed', + }) +# --- # name: test_all_entities[sensor.test_model_dhw_pump_flow-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -531,6 +655,64 @@ 'state': '1.12', }) # --- +# name: test_all_entities[sensor.test_model_dhw_target_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_dhw_target_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'DHW target temperature', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'DHW target temperature', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dhw_target_temperature', + 'unique_id': '0000-1111-2222-3333_dhw_target_temperature', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_dhw_target_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'temperature', + : 'Test Model DHW target temperature', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_model_dhw_target_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '55', + }) +# --- # name: test_all_entities[sensor.test_model_dhw_top_temperature-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ @@ -879,6 +1061,64 @@ 'state': '12345', }) # --- +# name: test_all_entities[sensor.test_model_electricity_used_indoor_unit-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + : , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.test_model_electricity_used_indoor_unit', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Electricity used indoor unit', + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Electricity used indoor unit', + 'platform': 'weheat', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'electricity_used_indoor_unit', + 'unique_id': '0000-1111-2222-3333_electricity_used_indoor_unit', + 'unit_of_measurement': , + }) +# --- +# name: test_all_entities[sensor.test_model_electricity_used_indoor_unit-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + : 'energy', + : 'Test Model Electricity used indoor unit', + : , + : , + }), + 'context': , + 'entity_id': 'sensor.test_model_electricity_used_indoor_unit', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1042', + }) +# --- # name: test_all_entities[sensor.test_model_electricity_used_standby-entry] EntityRegistryEntrySnapshot({ 'aliases': list([ diff --git a/tests/components/weheat/test_diagnostics.py b/tests/components/weheat/test_diagnostics.py new file mode 100644 index 00000000000000..ed4868af97383e --- /dev/null +++ b/tests/components/weheat/test_diagnostics.py @@ -0,0 +1,50 @@ +"""Tests for the weheat diagnostics.""" + +from unittest.mock import AsyncMock + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry +from tests.components.diagnostics import get_diagnostics_for_config_entry +from tests.typing import ClientSessionGenerator + + +@pytest.mark.usefixtures("mock_weheat_discover", "mock_weheat_heat_pump") +async def test_diagnostics( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, +) -> None: + """Test the diagnostics of a config entry.""" + await setup_integration(hass, mock_config_entry) + + assert ( + await get_diagnostics_for_config_entry(hass, hass_client, mock_config_entry) + == snapshot + ) + + +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_diagnostics_without_data( + hass: HomeAssistant, + hass_client: ClientSessionGenerator, + mock_config_entry: MockConfigEntry, + mock_weheat_heat_pump: AsyncMock, +) -> None: + """Test the diagnostics of a heat pump that reported nothing yet.""" + mock_weheat_heat_pump.raw_content = None + + await setup_integration(hass, mock_config_entry) + + diagnostics = await get_diagnostics_for_config_entry( + hass, hass_client, mock_config_entry + ) + + assert diagnostics["heat_pumps"][0]["logs"] == {} + assert diagnostics["heat_pumps"][0]["energy"] == {} diff --git a/tests/components/weheat/test_sensor.py b/tests/components/weheat/test_sensor.py index cdcfdb2746dac7..48c089761180b4 100644 --- a/tests/components/weheat/test_sensor.py +++ b/tests/components/weheat/test_sensor.py @@ -6,7 +6,7 @@ from syrupy.assertion import SnapshotAssertion from weheat.abstractions.discovery import HeatPumpDiscovery -from homeassistant.const import Platform +from homeassistant.const import STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -33,7 +33,7 @@ async def test_all_entities( await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) -@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 23), (True, 28)]) +@pytest.mark.parametrize(("has_dhw", "nr_of_entities"), [(False, 25), (True, 32)]) async def test_create_entities( hass: HomeAssistant, mock_weheat_discover: AsyncMock, @@ -52,3 +52,46 @@ async def test_create_entities( await hass.async_block_till_done() assert len(hass.states.async_all()) == nr_of_entities + + +@pytest.mark.parametrize( + ("target", "expected"), + [ + pytest.param(55, "55", id="a_target_it_aims_for"), + # DHW control off reports a target of zero, which is no target at all + pytest.param(0, STATE_UNKNOWN, id="dhw_control_off"), + ], +) +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_dhw_target_temperature( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, + target: int, + expected: str, +) -> None: + """Test the DHW target is only reported when the heat pump has one.""" + mock_weheat_heat_pump.dhw_target_temperature = target + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.test_model_dhw_target_temperature").state == expected + + +@pytest.mark.usefixtures("mock_weheat_discover") +async def test_an_unknown_dhw_control_method_keeps_the_sensor( + hass: HomeAssistant, + mock_weheat_heat_pump: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a control method this library cannot name still gets a sensor.""" + mock_weheat_heat_pump.dhw_control_method = None + mock_weheat_heat_pump.dhw_control_method_code = 99 + + with patch("homeassistant.components.weheat.PLATFORMS", [Platform.SENSOR]): + await setup_integration(hass, mock_config_entry) + + assert ( + hass.states.get("sensor.test_model_dhw_control_method").state == STATE_UNKNOWN + )