diff --git a/CODEOWNERS b/CODEOWNERS index 133700b75a405..2f5743fec50bb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1694,8 +1694,8 @@ build.json @home-assistant/supervisor /tests/components/vegehub/ @ghowevege /homeassistant/components/velbus/ @Cereal2nd @brefra /tests/components/velbus/ @Cereal2nd @brefra -/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio -/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio +/homeassistant/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew +/tests/components/velux/ @Julius2342 @DeerMaximum @pawlizio @wollew /homeassistant/components/venstar/ @garbled1 @jhollowe /tests/components/venstar/ @garbled1 @jhollowe /homeassistant/components/versasense/ @imstevenxyz diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ff50567257ac5..cf54565fe03b2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -75,39 +75,29 @@ _LOGGER = logging.getLogger(__name__) -EXTENDED_THEME_SCHEMA = vol.Schema( +THEME_SCHEMA = vol.Schema( { # Theme variables that apply to all modes cv.string: cv.string, # Mode specific theme variables - vol.Optional(CONF_THEMES_MODES): vol.Schema( + vol.Optional(CONF_THEMES_MODES): vol.All( { vol.Optional(CONF_THEMES_LIGHT): vol.Schema({cv.string: cv.string}), vol.Optional(CONF_THEMES_DARK): vol.Schema({cv.string: cv.string}), - } + }, + cv.has_at_least_one_key(CONF_THEMES_LIGHT, CONF_THEMES_DARK), ), } ) -THEME_SCHEMA = vol.Schema( - { - cv.string: ( - vol.Any( - # Legacy theme scheme - {cv.string: cv.string}, - # New extended schema with mode support - EXTENDED_THEME_SCHEMA, - ) - ) - } -) +THEMES_SCHEMA = vol.Schema({cv.string: THEME_SCHEMA}) CONFIG_SCHEMA = vol.Schema( { DOMAIN: vol.Schema( { vol.Optional(CONF_FRONTEND_REPO): cv.isdir, - vol.Optional(CONF_THEMES): THEME_SCHEMA, + vol.Optional(CONF_THEMES): THEMES_SCHEMA, vol.Optional(CONF_EXTRA_MODULE_URL): vol.All( cv.ensure_list, [cv.string] ), @@ -546,7 +536,7 @@ async def reload_themes(_: ServiceCall) -> None: new_themes = config.get(DOMAIN, {}).get(CONF_THEMES, {}) try: - THEME_SCHEMA(new_themes) + THEMES_SCHEMA(new_themes) except vol.Invalid as err: raise HomeAssistantError(f"Failed to reload themes: {err}") from err diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index 0f5a066600c81..8e01b6b6ae0b1 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -29,8 +29,7 @@ DEFAULT_LANGUAGE, DOMAIN, ) -from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator -from .entity import JewishCalendarConfigEntry +from .entity import JewishCalendarConfigEntry, JewishCalendarData from .services import async_setup_services _LOGGER = logging.getLogger(__name__) @@ -70,7 +69,7 @@ async def async_setup_entry( ) ) - data = JewishCalendarData( + config_entry.runtime_data = JewishCalendarData( language, diaspora, location, @@ -78,11 +77,8 @@ async def async_setup_entry( havdalah_offset, ) - coordinator = JewishCalendarUpdateCoordinator(hass, config_entry, data) - await coordinator.async_config_entry_first_refresh() - - config_entry.runtime_data = coordinator await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + return True @@ -90,13 +86,7 @@ async def async_unload_entry( hass: HomeAssistant, config_entry: JewishCalendarConfigEntry ) -> bool: """Unload a config entry.""" - if unload_ok := await hass.config_entries.async_unload_platforms( - config_entry, PLATFORMS - ): - coordinator = config_entry.runtime_data - if coordinator.event_unsub: - coordinator.event_unsub() - return unload_ok + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) async def async_migrate_entry( diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index 205691bc1838c..d5097df962f5c 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -72,7 +72,8 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity): @property def is_on(self) -> bool: """Return true if sensor is on.""" - return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now()) + zmanim = self.make_zmanim(dt.date.today()) + return self.entity_description.is_on(zmanim)(dt_util.now()) def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: """Return a list of times to update the sensor.""" diff --git a/homeassistant/components/jewish_calendar/coordinator.py b/homeassistant/components/jewish_calendar/coordinator.py deleted file mode 100644 index 21713313043e7..0000000000000 --- a/homeassistant/components/jewish_calendar/coordinator.py +++ /dev/null @@ -1,116 +0,0 @@ -"""Data update coordinator for Jewish calendar.""" - -from dataclasses import dataclass -import datetime as dt -import logging - -from hdate import HDateInfo, Location, Zmanim -from hdate.translator import Language, set_language - -from homeassistant.config_entries import ConfigEntry -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.helpers import event -from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -import homeassistant.util.dt as dt_util - -from .const import DOMAIN - -_LOGGER = logging.getLogger(__name__) - -type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarUpdateCoordinator] - - -@dataclass -class JewishCalendarData: - """Jewish Calendar runtime dataclass.""" - - language: Language - diaspora: bool - location: Location - candle_lighting_offset: int - havdalah_offset: int - dateinfo: HDateInfo | None = None - zmanim: Zmanim | None = None - - -class JewishCalendarUpdateCoordinator(DataUpdateCoordinator[JewishCalendarData]): - """Data update coordinator class for Jewish calendar.""" - - config_entry: JewishCalendarConfigEntry - event_unsub: CALLBACK_TYPE | None = None - - def __init__( - self, - hass: HomeAssistant, - config_entry: JewishCalendarConfigEntry, - data: JewishCalendarData, - ) -> None: - """Initialize the coordinator.""" - super().__init__(hass, _LOGGER, name=DOMAIN, config_entry=config_entry) - self.data = data - self._unsub_update: CALLBACK_TYPE | None = None - set_language(data.language) - - async def _async_update_data(self) -> JewishCalendarData: - """Return HDate and Zmanim for today.""" - now = dt_util.now() - _LOGGER.debug("Now: %s Location: %r", now, self.data.location) - - today = now.date() - - self.data.dateinfo = HDateInfo(today, self.data.diaspora) - self.data.zmanim = self.make_zmanim(today) - self.async_schedule_future_update() - return self.data - - @callback - def async_schedule_future_update(self) -> None: - """Schedule the next update of the sensor for the upcoming midnight.""" - # Cancel any existing update - if self._unsub_update: - self._unsub_update() - self._unsub_update = None - - # Calculate the next midnight - next_midnight = dt_util.start_of_local_day() + dt.timedelta(days=1) - - _LOGGER.debug("Scheduling next update at %s", next_midnight) - - # Schedule update at next midnight - self._unsub_update = event.async_track_point_in_time( - self.hass, self._handle_midnight_update, next_midnight - ) - - @callback - def _handle_midnight_update(self, _now: dt.datetime) -> None: - """Handle midnight update callback.""" - self._unsub_update = None - self.async_set_updated_data(self.data) - - async def async_shutdown(self) -> None: - """Cancel any scheduled updates when the coordinator is shutting down.""" - await super().async_shutdown() - if self._unsub_update: - self._unsub_update() - self._unsub_update = None - - def make_zmanim(self, date: dt.date) -> Zmanim: - """Create a Zmanim object.""" - return Zmanim( - date=date, - location=self.data.location, - candle_lighting_offset=self.data.candle_lighting_offset, - havdalah_offset=self.data.havdalah_offset, - ) - - @property - def zmanim(self) -> Zmanim: - """Return the current Zmanim.""" - assert self.data.zmanim is not None, "Zmanim data not available" - return self.data.zmanim - - @property - def dateinfo(self) -> HDateInfo: - """Return the current HDateInfo.""" - assert self.data.dateinfo is not None, "HDateInfo data not available" - return self.data.dateinfo diff --git a/homeassistant/components/jewish_calendar/diagnostics.py b/homeassistant/components/jewish_calendar/diagnostics.py index f2db0786b1282..27415282b6df5 100644 --- a/homeassistant/components/jewish_calendar/diagnostics.py +++ b/homeassistant/components/jewish_calendar/diagnostics.py @@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics( return { "entry_data": async_redact_data(entry.data, TO_REDACT), - "data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT), + "data": async_redact_data(asdict(entry.runtime_data), TO_REDACT), } diff --git a/homeassistant/components/jewish_calendar/entity.py b/homeassistant/components/jewish_calendar/entity.py index d3007212739bd..d5e4112907575 100644 --- a/homeassistant/components/jewish_calendar/entity.py +++ b/homeassistant/components/jewish_calendar/entity.py @@ -1,22 +1,48 @@ """Entity representing a Jewish Calendar sensor.""" from abc import abstractmethod +from dataclasses import dataclass import datetime as dt +import logging -from hdate import Zmanim +from hdate import HDateInfo, Location, Zmanim +from hdate.translator import Language, set_language +from homeassistant.config_entries import ConfigEntry from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.helpers import event from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo -from homeassistant.helpers.entity import EntityDescription -from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.util import dt as dt_util from .const import DOMAIN -from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator +_LOGGER = logging.getLogger(__name__) -class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]): +type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData] + + +@dataclass +class JewishCalendarDataResults: + """Jewish Calendar results dataclass.""" + + dateinfo: HDateInfo + zmanim: Zmanim + + +@dataclass +class JewishCalendarData: + """Jewish Calendar runtime dataclass.""" + + language: Language + diaspora: bool + location: Location + candle_lighting_offset: int + havdalah_offset: int + results: JewishCalendarDataResults | None = None + + +class JewishCalendarEntity(Entity): """An HA implementation for Jewish Calendar entity.""" _attr_has_entity_name = True @@ -29,13 +55,23 @@ def __init__( description: EntityDescription, ) -> None: """Initialize a Jewish Calendar entity.""" - super().__init__(config_entry.runtime_data) self.entity_description = description self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, identifiers={(DOMAIN, config_entry.entry_id)}, ) + self.data = config_entry.runtime_data + set_language(self.data.language) + + def make_zmanim(self, date: dt.date) -> Zmanim: + """Create a Zmanim object.""" + return Zmanim( + date=date, + location=self.data.location, + candle_lighting_offset=self.data.candle_lighting_offset, + havdalah_offset=self.data.havdalah_offset, + ) async def async_added_to_hass(self) -> None: """Call when entity is added to hass.""" @@ -56,9 +92,10 @@ def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: def _schedule_update(self) -> None: """Schedule the next update of the sensor.""" now = dt_util.now() + zmanim = self.make_zmanim(now.date()) update = dt_util.start_of_local_day() + dt.timedelta(days=1) - for update_time in self._update_times(self.coordinator.zmanim): + for update_time in self._update_times(zmanim): if update_time is not None and now < update_time < update: update = update_time @@ -73,4 +110,17 @@ def _update(self, now: dt.datetime | None = None) -> None: """Update the sensor data.""" self._update_unsub = None self._schedule_update() + self.create_results(now) self.async_write_ha_state() + + def create_results(self, now: dt.datetime | None = None) -> None: + """Create the results for the sensor.""" + if now is None: + now = dt_util.now() + + _LOGGER.debug("Now: %s Location: %r", now, self.data.location) + + today = now.date() + zmanim = self.make_zmanim(today) + dateinfo = HDateInfo(today, diaspora=self.data.diaspora) + self.data.results = JewishCalendarDataResults(dateinfo, zmanim) diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 579c8e0f6a686..d9ad89237f53c 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -19,7 +19,7 @@ from homeassistant.const import EntityCategory from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -import homeassistant.util.dt as dt_util +from homeassistant.util import dt as dt_util from .entity import JewishCalendarConfigEntry, JewishCalendarEntity @@ -236,18 +236,25 @@ def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: return [] return [self.entity_description.next_update_fn(zmanim)] - def get_dateinfo(self) -> HDateInfo: + def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo: """Get the next date info.""" - now = dt_util.now() - update = None + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" + + if now is None: + now = dt_util.now() + today = now.date() + zmanim = self.make_zmanim(today) + update = None if self.entity_description.next_update_fn: - update = self.entity_description.next_update_fn(self.coordinator.zmanim) + update = self.entity_description.next_update_fn(zmanim) - _LOGGER.debug("Today: %s, update: %s", now.date(), update) + _LOGGER.debug("Today: %s, update: %s", today, update) if update is not None and now >= update: - return self.coordinator.dateinfo.next_day - return self.coordinator.dateinfo + return self.data.results.dateinfo.next_day + return self.data.results.dateinfo class JewishCalendarSensor(JewishCalendarBaseSensor): @@ -264,9 +271,7 @@ def __init__( super().__init__(config_entry, description) # Set the options for enumeration sensors if self.entity_description.options_fn is not None: - self._attr_options = self.entity_description.options_fn( - self.coordinator.data.diaspora - ) + self._attr_options = self.entity_description.options_fn(self.data.diaspora) @property def native_value(self) -> str | int | dt.datetime | None: @@ -290,8 +295,9 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor): @property def native_value(self) -> dt.datetime | None: """Return the state of the sensor.""" + if self.data.results is None: + self.create_results() + assert self.data.results is not None, "Results should be available" if self.entity_description.value_fn is None: - return self.coordinator.zmanim.zmanim[self.entity_description.key].local - return self.entity_description.value_fn( - self.get_dateinfo(), self.coordinator.make_zmanim - ) + return self.data.results.zmanim.zmanim[self.entity_description.key].local + return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 32be29c3c9142..f31c4877ffdd0 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -4,8 +4,15 @@ from typing import Any, cast -from pyvlx import OpeningDevice, Position -from pyvlx.opening_device import Awning, Blind, GarageDoor, Gate, RollerShutter +from pyvlx import ( + Awning, + Blind, + GarageDoor, + Gate, + OpeningDevice, + Position, + RollerShutter, +) from homeassistant.components.cover import ( ATTR_POSITION, @@ -97,7 +104,10 @@ def current_cover_tilt_position(self) -> int | None: @property def is_closed(self) -> bool: """Return if the cover is closed.""" - return self.node.position.closed + # do not use the node's closed state but rely on cover position + # until https://github.com/Julius2342/pyvlx/pull/543 is merged. + # once merged this can again return self.node.position.closed + return self.current_cover_position == 0 @property def is_opening(self) -> bool: diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index cb21fef299df2..11e939fdfe77b 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -1,7 +1,7 @@ { "domain": "velux", "name": "Velux", - "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio"], + "codeowners": ["@Julius2342", "@DeerMaximum", "@pawlizio", "@wollew"], "config_flow": true, "dhcp": [ { diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py index a6c35513dc3e8..5695a3bca15ad 100644 --- a/tests/components/frontend/test_init.py +++ b/tests/components/frontend/test_init.py @@ -410,8 +410,64 @@ async def test_themes_reload_themes( @pytest.mark.usefixtures("frontend") +@pytest.mark.parametrize( + ("invalid_theme", "error"), + [ + ( + { + "invalid0": "blue", + }, + "expected a dictionary", + ), + ( + { + "invalid1": { + "primary-color": "black", + "modes": "light:{} dark:{}", + } + }, + "expected a dictionary.*modes", + ), + ( + { + "invalid2": None, + }, + "expected a dictionary", + ), + ( + { + "invalid3": { + "primary-color": "black", + "modes": {}, + } + }, + "at least one of light, dark.*modes", + ), + ( + { + "invalid4": { + "primary-color": "black", + "modes": None, + } + }, + "expected a dictionary.*modes", + ), + ( + { + "invalid5": { + "primary-color": "black", + "modes": {"light": {}, "dank": {}}, + } + }, + "extra keys not allowed.*dank", + ), + ], +) async def test_themes_reload_invalid( - hass: HomeAssistant, themes_ws_client: MockHAClientWebSocket + hass: HomeAssistant, + themes_ws_client: MockHAClientWebSocket, + invalid_theme: dict, + error: str, ) -> None: """Test frontend.reload_themes service with an invalid theme.""" @@ -424,9 +480,9 @@ async def test_themes_reload_invalid( with ( patch( "homeassistant.components.frontend.async_hass_config_yaml", - return_value={DOMAIN: {CONF_THEMES: {"sad": "blue"}}}, + return_value={DOMAIN: {CONF_THEMES: invalid_theme}}, ), - pytest.raises(HomeAssistantError, match="Failed to reload themes"), + pytest.raises(HomeAssistantError, match=rf"Failed to reload themes.*{error}"), ): await hass.services.async_call(DOMAIN, "reload_themes", blocking=True) diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 859cdefd9c2af..0a392e101c515 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -3,15 +3,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 40, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -26,22 +17,33 @@ 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 40, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 40, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", + }), }), }), }), @@ -57,15 +59,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': True, - 'nusach': 'sephardi', - }), 'diaspora': True, 'havdalah_offset': 0, 'language': 'en', @@ -80,22 +73,33 @@ 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': True, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': True, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='America/New_York')", + }), }), }), }), @@ -111,15 +115,6 @@ dict({ 'data': dict({ 'candle_lighting_offset': 18, - 'dateinfo': dict({ - 'date': dict({ - 'day': 21, - 'month': 10, - 'year': 5785, - }), - 'diaspora': False, - 'nusach': 'sephardi', - }), 'diaspora': False, 'havdalah_offset': 0, 'language': 'en', @@ -134,22 +129,33 @@ 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", }), }), - 'zmanim': dict({ - 'candle_lighting_offset': 18, - 'date': dict({ - '__type': "", - 'isoformat': '2025-05-19', - }), - 'havdalah_offset': 0, - 'location': dict({ - 'altitude': '**REDACTED**', + 'results': dict({ + 'dateinfo': dict({ + 'date': dict({ + 'day': 21, + 'month': 10, + 'year': 5785, + }), 'diaspora': False, - 'latitude': '**REDACTED**', - 'longitude': '**REDACTED**', - 'name': 'test home', - 'timezone': dict({ - '__type': "", - 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + 'nusach': 'sephardi', + }), + 'zmanim': dict({ + 'candle_lighting_offset': 18, + 'date': dict({ + '__type': "", + 'isoformat': '2025-05-19', + }), + 'havdalah_offset': 0, + 'location': dict({ + 'altitude': '**REDACTED**', + 'diaspora': False, + 'latitude': '**REDACTED**', + 'longitude': '**REDACTED**', + 'name': 'test home', + 'timezone': dict({ + '__type': "", + 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", + }), }), }), }), diff --git a/tests/components/lifx/test_sensor.py b/tests/components/lifx/test_sensor.py index b7ff563bdbc0f..bca4b7cd790af 100644 --- a/tests/components/lifx/test_sensor.py +++ b/tests/components/lifx/test_sensor.py @@ -4,6 +4,8 @@ from datetime import timedelta +import pytest + from homeassistant.components import lifx from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( @@ -32,6 +34,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed +@pytest.mark.usefixtures("mock_discovery") async def test_rssi_sensor( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: @@ -88,6 +91,7 @@ async def test_rssi_sensor( assert rssi.attributes["state_class"] == SensorStateClass.MEASUREMENT +@pytest.mark.usefixtures("mock_discovery") async def test_rssi_sensor_old_firmware( hass: HomeAssistant, entity_registry: er.EntityRegistry ) -> None: diff --git a/tests/components/velux/__init__.py b/tests/components/velux/__init__.py index 6cf5cd366fb15..b50a46b1150db 100644 --- a/tests/components/velux/__init__.py +++ b/tests/components/velux/__init__.py @@ -1 +1,31 @@ """Tests for the Velux integration.""" + +from unittest.mock import MagicMock + +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.helpers.device_registry import HomeAssistant +from homeassistant.helpers.entity_platform import timedelta + +from tests.common import async_fire_time_changed + + +async def update_callback_entity( + hass: HomeAssistant, mock_velux_node: MagicMock +) -> None: + """Simulate an update triggered by the pyvlx lib for a Velux node.""" + + callback = mock_velux_node.register_device_updated_cb.call_args[0][0] + await callback(mock_velux_node) + await hass.async_block_till_done() + + +async def update_polled_entities( + hass: HomeAssistant, freezer: FrozenDateTimeFactory +) -> None: + """Simulate an update trigger from polling.""" + # just fire a time changed event to trigger the polling + + freezer.tick(timedelta(minutes=5)) + async_fire_time_changed(hass) + await hass.async_block_till_done() diff --git a/tests/components/velux/conftest.py b/tests/components/velux/conftest.py index 1b7066577ad4d..22fc1a933579d 100644 --- a/tests/components/velux/conftest.py +++ b/tests/components/velux/conftest.py @@ -72,6 +72,9 @@ def mock_window() -> AsyncMock: window.rain_sensor = True window.serial_number = "123456789" window.get_limitation.return_value = MagicMock(min_value=0) + window.is_opening = False + window.is_closing = False + window.position = MagicMock(position_percent=30, closed=False) return window diff --git a/tests/components/velux/test_binary_sensor.py b/tests/components/velux/test_binary_sensor.py index dfe994b6fa2e3..ecb94d5f58db5 100644 --- a/tests/components/velux/test_binary_sensor.py +++ b/tests/components/velux/test_binary_sensor.py @@ -1,6 +1,5 @@ """Tests for the Velux binary sensor platform.""" -from datetime import timedelta from unittest.mock import MagicMock, patch from freezegun.api import FrozenDateTimeFactory @@ -11,7 +10,9 @@ from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry -from tests.common import MockConfigEntry, async_fire_time_changed +from . import update_polled_entities + +from tests.common import MockConfigEntry @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -33,18 +34,14 @@ async def test_rain_sensor_state( test_entity_id = "binary_sensor.test_window_rain_sensor" # simulate no rain detected - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_OFF # simulate rain detected mock_window.get_limitation.return_value.min_value = 93 - freezer.tick(timedelta(minutes=5)) - async_fire_time_changed(hass) - await hass.async_block_till_done() + await update_polled_entities(hass, freezer) state = hass.states.get(test_entity_id) assert state is not None assert state.state == STATE_ON diff --git a/tests/components/velux/test_cover.py b/tests/components/velux/test_cover.py new file mode 100644 index 0000000000000..621aa1c3b6c9f --- /dev/null +++ b/tests/components/velux/test_cover.py @@ -0,0 +1,48 @@ +"""Tests for the Velux cover platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +import pytest + +from homeassistant.const import STATE_CLOSED, STATE_OPEN, Platform +from homeassistant.core import HomeAssistant + +from . import update_callback_entity + +from tests.common import MockConfigEntry + + +@pytest.mark.usefixtures("mock_module") +async def test_cover_closed( + hass: HomeAssistant, + mock_window: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the cover closed state.""" + + mock_config_entry.add_to_hass(hass) + with patch("homeassistant.components.velux.PLATFORMS", [Platform.COVER]): + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + test_entity_id = "cover.test_window" + + # Initial state should be open + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_OPEN + + # Update mock window position to closed percentage + mock_window.position.position_percent = 100 + # Also directly set position to closed, so this test should + # continue to be green after the lib is fixed + mock_window.position.closed = True + + # Trigger entity state update via registered callback + await update_callback_entity(hass, mock_window) + + state = hass.states.get(test_entity_id) + assert state is not None + assert state.state == STATE_CLOSED