mirror of
https://github.com/home-assistant/core.git
synced 2026-02-15 07:36:16 +00:00
Jewish Calendar coordinator (#152434)
This commit is contained in:
@@ -29,7 +29,8 @@ from .const import (
|
||||
DEFAULT_LANGUAGE,
|
||||
DOMAIN,
|
||||
)
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarData
|
||||
from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator
|
||||
from .entity import JewishCalendarConfigEntry
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -69,7 +70,7 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
config_entry.runtime_data = JewishCalendarData(
|
||||
data = JewishCalendarData(
|
||||
language,
|
||||
diaspora,
|
||||
location,
|
||||
@@ -77,8 +78,11 @@ async def async_setup_entry(
|
||||
havdalah_offset,
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
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
|
||||
|
||||
|
||||
@@ -86,7 +90,12 @@ async def async_unload_entry(
|
||||
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
|
||||
) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(
|
||||
config_entry, PLATFORMS
|
||||
):
|
||||
coordinator = config_entry.runtime_data
|
||||
await coordinator.async_shutdown()
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_migrate_entry(
|
||||
|
||||
@@ -72,8 +72,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if sensor is on."""
|
||||
zmanim = self.make_zmanim(dt.date.today())
|
||||
return self.entity_description.is_on(zmanim)(dt_util.now())
|
||||
return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now())
|
||||
|
||||
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||
"""Return a list of times to update the sensor."""
|
||||
|
||||
105
homeassistant/components/jewish_calendar/coordinator.py
Normal file
105
homeassistant/components/jewish_calendar/coordinator.py
Normal file
@@ -0,0 +1,105 @@
|
||||
"""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 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(frozen=True)
|
||||
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
|
||||
_attr_should_poll = False
|
||||
|
||||
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
|
||||
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()
|
||||
|
||||
# Create new data object with today's information
|
||||
new_data = JewishCalendarData(
|
||||
language=self.data.language,
|
||||
diaspora=self.data.diaspora,
|
||||
location=self.data.location,
|
||||
candle_lighting_offset=self.data.candle_lighting_offset,
|
||||
havdalah_offset=self.data.havdalah_offset,
|
||||
dateinfo=HDateInfo(today, self.data.diaspora),
|
||||
zmanim=self.make_zmanim(today),
|
||||
)
|
||||
|
||||
# Schedule next update at 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_refresh = event.async_track_point_in_time(
|
||||
self.hass, self._handle_midnight_update, next_midnight
|
||||
)
|
||||
|
||||
return new_data
|
||||
|
||||
@callback
|
||||
def _handle_midnight_update(self, _now: dt.datetime) -> None:
|
||||
"""Handle midnight update callback."""
|
||||
self.hass.async_create_task(self.async_request_refresh())
|
||||
|
||||
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
|
||||
@@ -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), TO_REDACT),
|
||||
"data": async_redact_data(asdict(entry.runtime_data.data), TO_REDACT),
|
||||
}
|
||||
|
||||
@@ -1,48 +1,22 @@
|
||||
"""Entity representing a Jewish Calendar sensor."""
|
||||
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
import logging
|
||||
|
||||
from hdate import HDateInfo, Location, Zmanim
|
||||
from hdate.translator import Language, set_language
|
||||
from hdate import Zmanim
|
||||
|
||||
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 Entity, EntityDescription
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
|
||||
from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator
|
||||
|
||||
|
||||
@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):
|
||||
class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
|
||||
"""An HA implementation for Jewish Calendar entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
@@ -55,23 +29,13 @@ class JewishCalendarEntity(Entity):
|
||||
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."""
|
||||
@@ -85,6 +49,14 @@ class JewishCalendarEntity(Entity):
|
||||
self._update_unsub = None
|
||||
return await super().async_will_remove_from_hass()
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
# When coordinator updates (e.g., from tests forcing refresh or midnight update),
|
||||
# reschedule our entity-specific updates
|
||||
self._schedule_update()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@abstractmethod
|
||||
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
|
||||
"""Return a list of times to update the sensor."""
|
||||
@@ -92,10 +64,9 @@ class JewishCalendarEntity(Entity):
|
||||
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(zmanim):
|
||||
for update_time in self._update_times(self.coordinator.zmanim):
|
||||
if update_time is not None and now < update_time < update:
|
||||
update = update_time
|
||||
|
||||
@@ -110,17 +81,4 @@ class JewishCalendarEntity(Entity):
|
||||
"""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)
|
||||
|
||||
@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import dt as dt_util
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
|
||||
|
||||
@@ -238,25 +238,18 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
|
||||
return []
|
||||
return [self.entity_description.next_update_fn(zmanim)]
|
||||
|
||||
def get_dateinfo(self, now: dt.datetime | None = None) -> HDateInfo:
|
||||
def get_dateinfo(self) -> HDateInfo:
|
||||
"""Get the next date info."""
|
||||
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)
|
||||
now = dt_util.now()
|
||||
update = None
|
||||
if self.entity_description.next_update_fn:
|
||||
update = self.entity_description.next_update_fn(zmanim)
|
||||
|
||||
_LOGGER.debug("Today: %s, update: %s", today, update)
|
||||
if self.entity_description.next_update_fn:
|
||||
update = self.entity_description.next_update_fn(self.coordinator.zmanim)
|
||||
|
||||
_LOGGER.debug("Today: %s, update: %s", now.date(), update)
|
||||
if update is not None and now >= update:
|
||||
return self.data.results.dateinfo.next_day
|
||||
return self.data.results.dateinfo
|
||||
return self.coordinator.dateinfo.next_day
|
||||
return self.coordinator.dateinfo
|
||||
|
||||
|
||||
class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
@@ -273,7 +266,9 @@ class JewishCalendarSensor(JewishCalendarBaseSensor):
|
||||
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.data.diaspora)
|
||||
self._attr_options = self.entity_description.options_fn(
|
||||
self.coordinator.data.diaspora
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | int | dt.datetime | None:
|
||||
@@ -297,9 +292,8 @@ 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.data.results.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim)
|
||||
return self.coordinator.zmanim.zmanim[self.entity_description.key].local
|
||||
return self.entity_description.value_fn(
|
||||
self.get_dateinfo(), self.coordinator.make_zmanim
|
||||
)
|
||||
|
||||
@@ -1 +1,19 @@
|
||||
"""Tests for the jewish_calendar component."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
import datetime as dt
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimeValue:
|
||||
"""Single test case."""
|
||||
|
||||
time: dt.datetime
|
||||
expected: str | int | bool | list | dict | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TimeValueSequence:
|
||||
"""Sequence of test cases."""
|
||||
|
||||
cases: list[TimeValue]
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
"""Common fixtures for the jewish_calendar tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncGenerator, Generator, Iterable
|
||||
import datetime as dt
|
||||
from typing import NamedTuple
|
||||
from typing import Any, NamedTuple
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from freezegun import freeze_time
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from hdate.translator import set_language
|
||||
import pytest
|
||||
|
||||
@@ -20,7 +23,9 @@ from homeassistant.const import CONF_LANGUAGE, CONF_TIME_ZONE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from . import TimeValue
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
class _LocationData(NamedTuple):
|
||||
@@ -161,3 +166,48 @@ async def setup_at_time(
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def test_sequence(
|
||||
request: pytest.FixtureRequest,
|
||||
hass: HomeAssistant,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
config_entry: MockConfigEntry,
|
||||
tz_info: dt.tzinfo,
|
||||
) -> None:
|
||||
"""Set up time sequence testing fixture.
|
||||
|
||||
This fixture:
|
||||
1. Sets up the integration at the first time point
|
||||
2. Yields the expected state value for each time point
|
||||
3. Then moves through each time point, yielding the expected state
|
||||
|
||||
The test should compare the yielded expected state with the actual entity state.
|
||||
"""
|
||||
# We expect a sequence of TimeStatePoint objects
|
||||
if not hasattr(request, "param") or not request.param:
|
||||
raise ValueError("time_sequence fixture requires parameters")
|
||||
|
||||
sequence: list[TimeValue] = request.param.cases
|
||||
|
||||
async def _time_sequence() -> AsyncGenerator[Any]:
|
||||
# Setup at the initial time
|
||||
with freeze_time(sequence[0].time.replace(tzinfo=tz_info)):
|
||||
config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Yield the expected state
|
||||
yield sequence[0].expected
|
||||
|
||||
# Move through subsequent time points
|
||||
for data_point in sequence[1:]:
|
||||
freezer.move_to(data_point.time.replace(tzinfo=tz_info))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Yield the expected state
|
||||
yield data_point.expected
|
||||
|
||||
return _time_sequence
|
||||
|
||||
@@ -3,6 +3,15 @@
|
||||
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',
|
||||
@@ -17,33 +26,22 @@
|
||||
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
|
||||
}),
|
||||
}),
|
||||
'results': dict({
|
||||
'dateinfo': dict({
|
||||
'date': dict({
|
||||
'day': 21,
|
||||
'month': 10,
|
||||
'year': 5785,
|
||||
}),
|
||||
'diaspora': False,
|
||||
'nusach': 'sephardi',
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 40,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 40,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': False,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': False,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -59,6 +57,15 @@
|
||||
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',
|
||||
@@ -73,33 +80,22 @@
|
||||
'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
|
||||
}),
|
||||
}),
|
||||
'results': dict({
|
||||
'dateinfo': dict({
|
||||
'date': dict({
|
||||
'day': 21,
|
||||
'month': 10,
|
||||
'year': 5785,
|
||||
}),
|
||||
'diaspora': True,
|
||||
'nusach': 'sephardi',
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 18,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 18,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': True,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': True,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
@@ -115,6 +111,15 @@
|
||||
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',
|
||||
@@ -129,33 +134,22 @@
|
||||
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
|
||||
}),
|
||||
}),
|
||||
'results': dict({
|
||||
'dateinfo': dict({
|
||||
'date': dict({
|
||||
'day': 21,
|
||||
'month': 10,
|
||||
'year': 5785,
|
||||
}),
|
||||
'diaspora': False,
|
||||
'nusach': 'sephardi',
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 18,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'zmanim': dict({
|
||||
'candle_lighting_offset': 18,
|
||||
'date': dict({
|
||||
'__type': "<class 'datetime.date'>",
|
||||
'isoformat': '2025-05-19',
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': False,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
|
||||
}),
|
||||
'havdalah_offset': 0,
|
||||
'location': dict({
|
||||
'altitude': '**REDACTED**',
|
||||
'diaspora': False,
|
||||
'latitude': '**REDACTED**',
|
||||
'longitude': '**REDACTED**',
|
||||
'name': 'test home',
|
||||
'timezone': dict({
|
||||
'__type': "<class 'zoneinfo.ZoneInfo'>",
|
||||
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
|
||||
@@ -1,139 +1,209 @@
|
||||
"""The tests for the Jewish calendar binary sensors."""
|
||||
|
||||
from datetime import datetime as dt, timedelta
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
from . import TimeValue, TimeValueSequence
|
||||
|
||||
MELACHA_PARAMS = [
|
||||
# Test sequences for issur melacha (forbidden work) binary sensor
|
||||
MELACHA_TEST_SEQUENCES = [
|
||||
# New York scenarios
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 1, 16, 0),
|
||||
{"state": STATE_ON, "update": dt(2018, 9, 1, 20, 14), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 1, 16, 0), STATE_ON),
|
||||
TimeValue(dt(2018, 9, 1, 20, 14), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="currently_first_shabbat",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 1, 20, 21),
|
||||
{"state": STATE_OFF, "update": dt(2018, 9, 2, 6, 21), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 1, 20, 21), STATE_OFF),
|
||||
TimeValue(dt(2018, 9, 2, 6, 21), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="after_first_shabbat",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 7, 13, 1),
|
||||
{"state": STATE_OFF, "update": dt(2018, 9, 7, 19, 4), "new_state": STATE_ON},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 7, 13, 1), STATE_OFF),
|
||||
TimeValue(dt(2018, 9, 7, 19, 4), STATE_ON),
|
||||
]
|
||||
),
|
||||
id="friday_upcoming_shabbat",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 8, 21, 25),
|
||||
{"state": STATE_OFF, "update": dt(2018, 9, 9, 6, 27), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 8, 21, 25), STATE_OFF),
|
||||
TimeValue(dt(2018, 9, 9, 6, 27), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="upcoming_rosh_hashana",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 9, 21, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 9, 10, 6, 28), "new_state": STATE_ON},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 9, 21, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 9, 10, 6, 28), STATE_ON),
|
||||
]
|
||||
),
|
||||
id="currently_rosh_hashana",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 10, 21, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 9, 11, 6, 29), "new_state": STATE_ON},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 10, 21, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 9, 11, 6, 29), STATE_ON),
|
||||
]
|
||||
),
|
||||
id="second_day_rosh_hashana_night",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 11, 11, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 9, 11, 19, 57), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 11, 11, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 9, 11, 19, 57), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="second_day_rosh_hashana_day",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 29, 16, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 9, 29, 19, 25), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 29, 16, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 9, 29, 19, 25), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="currently_shabbat_chol_hamoed",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 29, 21, 25),
|
||||
{"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 48), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 29, 21, 25), STATE_OFF),
|
||||
TimeValue(dt(2018, 9, 30, 6, 48), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="upcoming_two_day_yomtov_in_diaspora",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 9, 30, 21, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 10, 1, 6, 49), "new_state": STATE_ON},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 30, 21, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 10, 1, 6, 49), STATE_ON),
|
||||
]
|
||||
),
|
||||
id="currently_first_day_of_two_day_yomtov_in_diaspora",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
dt(2018, 10, 1, 21, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 10, 2, 6, 50), "new_state": STATE_ON},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 10, 1, 21, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 10, 2, 6, 50), STATE_ON),
|
||||
]
|
||||
),
|
||||
id="currently_second_day_of_two_day_yomtov_in_diaspora",
|
||||
),
|
||||
# Jerusalem scenarios
|
||||
pytest.param(
|
||||
"Jerusalem",
|
||||
dt(2018, 9, 29, 21, 25),
|
||||
{"state": STATE_OFF, "update": dt(2018, 9, 30, 6, 29), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 9, 29, 21, 25), STATE_OFF),
|
||||
TimeValue(dt(2018, 9, 30, 6, 29), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="upcoming_one_day_yom_tov_in_israel",
|
||||
),
|
||||
pytest.param(
|
||||
"Jerusalem",
|
||||
dt(2018, 10, 1, 11, 25),
|
||||
{"state": STATE_ON, "update": dt(2018, 10, 1, 19, 2), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 10, 1, 11, 25), STATE_ON),
|
||||
TimeValue(dt(2018, 10, 1, 19, 2), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="currently_one_day_yom_tov_in_israel",
|
||||
),
|
||||
pytest.param(
|
||||
"Jerusalem",
|
||||
dt(2018, 10, 1, 21, 25),
|
||||
{"state": STATE_OFF, "update": dt(2018, 10, 2, 6, 31), "new_state": STATE_OFF},
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2018, 10, 1, 21, 25), STATE_OFF),
|
||||
TimeValue(dt(2018, 10, 2, 6, 31), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="after_one_day_yom_tov_in_israel",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("location_data", "test_time", "results"), MELACHA_PARAMS, indirect=True
|
||||
("location_data", "test_sequence"), MELACHA_TEST_SEQUENCES, indirect=True
|
||||
)
|
||||
@pytest.mark.usefixtures("setup_at_time")
|
||||
async def test_issur_melacha_sensor(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any]
|
||||
hass: HomeAssistant, test_sequence: AsyncGenerator[Any]
|
||||
) -> None:
|
||||
"""Test Issur Melacha sensor output."""
|
||||
sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
|
||||
assert hass.states.get(sensor_id).state == results["state"]
|
||||
|
||||
freezer.move_to(results["update"])
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(sensor_id).state == results["new_state"]
|
||||
async for expected_state in test_sequence():
|
||||
current_state = hass.states.get(sensor_id).state
|
||||
assert current_state == expected_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("location_data", "test_time", "results"),
|
||||
("location_data", "test_sequence"),
|
||||
[
|
||||
("New York", dt(2020, 10, 23, 17, 44, 59, 999999), [STATE_OFF, STATE_ON]),
|
||||
("New York", dt(2020, 10, 24, 18, 42, 59, 999999), [STATE_ON, STATE_OFF]),
|
||||
pytest.param(
|
||||
"New York",
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2020, 10, 23, 17, 44, 59, 999999), STATE_OFF),
|
||||
TimeValue(dt(2020, 10, 23, 17, 45, 0), STATE_ON),
|
||||
TimeValue(dt(2020, 10, 24, 18, 42, 59), STATE_ON),
|
||||
TimeValue(dt(2020, 10, 24, 18, 43, 0), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="full_shabbat_cycle",
|
||||
),
|
||||
pytest.param(
|
||||
"New York",
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2020, 10, 24, 18, 42, 59, 999999), STATE_ON),
|
||||
TimeValue(dt(2020, 10, 24, 18, 43, 0), STATE_OFF),
|
||||
]
|
||||
),
|
||||
id="havdalah_transition",
|
||||
),
|
||||
],
|
||||
ids=["before_candle_lighting", "before_havdalah"],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.usefixtures("setup_at_time")
|
||||
async def test_issur_melacha_sensor_update(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: list[str]
|
||||
async def test_issur_melacha_sensor_transitions(
|
||||
hass: HomeAssistant, test_sequence: AsyncGenerator[Any]
|
||||
) -> None:
|
||||
"""Test Issur Melacha sensor output."""
|
||||
"""Test Issur Melacha sensor transitions at key times."""
|
||||
sensor_id = "binary_sensor.jewish_calendar_issur_melacha_in_effect"
|
||||
assert hass.states.get(sensor_id).state == results[0]
|
||||
|
||||
freezer.tick(timedelta(microseconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(sensor_id).state == results[1]
|
||||
async for expected_state in test_sequence():
|
||||
current_state = hass.states.get(sensor_id).state
|
||||
assert current_state == expected_state
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
"""The tests for the Jewish calendar sensors."""
|
||||
|
||||
from collections.abc import AsyncGenerator
|
||||
from datetime import datetime as dt
|
||||
from typing import Any
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from hdate.holidays import HolidayDatabase
|
||||
from hdate.parasha import Parasha
|
||||
import pytest
|
||||
@@ -11,7 +11,9 @@ import pytest
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
from . import TimeValue, TimeValueSequence
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.mark.parametrize("language", ["en", "he"])
|
||||
@@ -542,28 +544,23 @@ async def test_dafyomi_sensor(hass: HomeAssistant, results: str) -> None:
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("test_time", "results"),
|
||||
"test_sequence",
|
||||
[
|
||||
(
|
||||
dt(2025, 6, 10, 17),
|
||||
{
|
||||
"initial_state": "14 Sivan 5785",
|
||||
"move_to": dt(2025, 6, 10, 23, 0),
|
||||
"new_state": "15 Sivan 5785",
|
||||
},
|
||||
),
|
||||
TimeValueSequence(
|
||||
[
|
||||
TimeValue(dt(2025, 6, 10, 17), "14 Sivan 5785"), # Initial time
|
||||
TimeValue(dt(2025, 6, 10, 23, 0), "15 Sivan 5785"), # Later in the day
|
||||
TimeValue(dt(2025, 6, 11, 9, 0), "15 Sivan 5785"), # Next morning
|
||||
TimeValue(dt(2025, 6, 11, 22, 0), "16 Sivan 5785"), # Next evening
|
||||
]
|
||||
)
|
||||
],
|
||||
indirect=True,
|
||||
)
|
||||
@pytest.mark.usefixtures("setup_at_time")
|
||||
async def test_sensor_does_not_update_on_time_change(
|
||||
hass: HomeAssistant, freezer: FrozenDateTimeFactory, results: dict[str, Any]
|
||||
async def test_sensor_date_changes_with_time(
|
||||
hass: HomeAssistant, test_sequence: AsyncGenerator[Any]
|
||||
) -> None:
|
||||
"""Test that the Jewish calendar sensor does not update after time advances (regression test for update bug)."""
|
||||
sensor_id = "sensor.jewish_calendar_date"
|
||||
assert hass.states.get(sensor_id).state == results["initial_state"]
|
||||
|
||||
freezer.move_to(results["move_to"])
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
assert hass.states.get(sensor_id).state == results["new_state"]
|
||||
"""Test that the Jewish calendar date sensor updates when time crosses date boundaries."""
|
||||
async for expected_state in test_sequence():
|
||||
current_state = hass.states.get("sensor.jewish_calendar_date").state
|
||||
assert current_state == expected_state
|
||||
|
||||
Reference in New Issue
Block a user