Jewish Calendar add coordinator (#141456)

Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
This commit is contained in:
Tsvi Mostovicz
2025-08-11 23:22:13 +03:00
committed by GitHub
parent e394435d7c
commit 3b358df9e7
7 changed files with 227 additions and 164 deletions

View File

@@ -29,7 +29,8 @@ from .const import (
DEFAULT_LANGUAGE, DEFAULT_LANGUAGE,
DOMAIN, DOMAIN,
) )
from .entity import JewishCalendarConfigEntry, JewishCalendarData from .coordinator import JewishCalendarData, JewishCalendarUpdateCoordinator
from .entity import JewishCalendarConfigEntry
from .services import async_setup_services from .services import async_setup_services
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@@ -69,7 +70,7 @@ async def async_setup_entry(
) )
) )
config_entry.runtime_data = JewishCalendarData( data = JewishCalendarData(
language, language,
diaspora, diaspora,
location, location,
@@ -77,8 +78,11 @@ async def async_setup_entry(
havdalah_offset, 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 return True
@@ -86,7 +90,13 @@ async def async_unload_entry(
hass: HomeAssistant, config_entry: JewishCalendarConfigEntry hass: HomeAssistant, config_entry: JewishCalendarConfigEntry
) -> bool: ) -> bool:
"""Unload a config entry.""" """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
if coordinator.event_unsub:
coordinator.event_unsub()
return unload_ok
async def async_migrate_entry( async def async_migrate_entry(

View File

@@ -72,8 +72,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
@property @property
def is_on(self) -> bool: def is_on(self) -> bool:
"""Return true if sensor is on.""" """Return true if sensor is on."""
zmanim = self.make_zmanim(dt.date.today()) return self.entity_description.is_on(self.coordinator.zmanim)(dt_util.now())
return self.entity_description.is_on(zmanim)(dt_util.now())
def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]: def _update_times(self, zmanim: Zmanim) -> list[dt.datetime | None]:
"""Return a list of times to update the sensor.""" """Return a list of times to update the sensor."""

View File

@@ -0,0 +1,116 @@
"""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

View File

@@ -24,5 +24,5 @@ async def async_get_config_entry_diagnostics(
return { return {
"entry_data": async_redact_data(entry.data, TO_REDACT), "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),
} }

View File

@@ -1,48 +1,22 @@
"""Entity representing a Jewish Calendar sensor.""" """Entity representing a Jewish Calendar sensor."""
from abc import abstractmethod from abc import abstractmethod
from dataclasses import dataclass
import datetime as dt import datetime as dt
import logging
from hdate import HDateInfo, Location, Zmanim from hdate import Zmanim
from hdate.translator import Language, set_language
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import CALLBACK_TYPE, callback from homeassistant.core import CALLBACK_TYPE, callback
from homeassistant.helpers import event from homeassistant.helpers import event
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo 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 homeassistant.util import dt as dt_util
from .const import DOMAIN from .const import DOMAIN
from .coordinator import JewishCalendarConfigEntry, JewishCalendarUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
@dataclass class JewishCalendarEntity(CoordinatorEntity[JewishCalendarUpdateCoordinator]):
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.""" """An HA implementation for Jewish Calendar entity."""
_attr_has_entity_name = True _attr_has_entity_name = True
@@ -55,23 +29,13 @@ class JewishCalendarEntity(Entity):
description: EntityDescription, description: EntityDescription,
) -> None: ) -> None:
"""Initialize a Jewish Calendar entity.""" """Initialize a Jewish Calendar entity."""
super().__init__(config_entry.runtime_data)
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{config_entry.entry_id}-{description.key}" self._attr_unique_id = f"{config_entry.entry_id}-{description.key}"
self._attr_device_info = DeviceInfo( self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE, entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)}, 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: async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass.""" """Call when entity is added to hass."""
@@ -92,10 +56,9 @@ class JewishCalendarEntity(Entity):
def _schedule_update(self) -> None: def _schedule_update(self) -> None:
"""Schedule the next update of the sensor.""" """Schedule the next update of the sensor."""
now = dt_util.now() now = dt_util.now()
zmanim = self.make_zmanim(now.date())
update = dt_util.start_of_local_day() + dt.timedelta(days=1) 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: if update_time is not None and now < update_time < update:
update = update_time update = update_time
@@ -110,17 +73,4 @@ class JewishCalendarEntity(Entity):
"""Update the sensor data.""" """Update the sensor data."""
self._update_unsub = None self._update_unsub = None
self._schedule_update() self._schedule_update()
self.create_results(now)
self.async_write_ha_state() 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)

View File

@@ -19,7 +19,7 @@ from homeassistant.components.sensor import (
from homeassistant.const import EntityCategory from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback 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 from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
@@ -236,25 +236,18 @@ class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
return [] return []
return [self.entity_description.next_update_fn(zmanim)] 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.""" """Get the next date info."""
if self.data.results is None: now = dt_util.now()
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 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: if update is not None and now >= update:
return self.data.results.dateinfo.next_day return self.coordinator.dateinfo.next_day
return self.data.results.dateinfo return self.coordinator.dateinfo
class JewishCalendarSensor(JewishCalendarBaseSensor): class JewishCalendarSensor(JewishCalendarBaseSensor):
@@ -271,7 +264,9 @@ class JewishCalendarSensor(JewishCalendarBaseSensor):
super().__init__(config_entry, description) super().__init__(config_entry, description)
# Set the options for enumeration sensors # Set the options for enumeration sensors
if self.entity_description.options_fn is not None: 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 @property
def native_value(self) -> str | int | dt.datetime | None: def native_value(self) -> str | int | dt.datetime | None:
@@ -295,9 +290,8 @@ class JewishCalendarTimeSensor(JewishCalendarBaseSensor):
@property @property
def native_value(self) -> dt.datetime | None: def native_value(self) -> dt.datetime | None:
"""Return the state of the sensor.""" """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: if self.entity_description.value_fn is None:
return self.data.results.zmanim.zmanim[self.entity_description.key].local return self.coordinator.zmanim.zmanim[self.entity_description.key].local
return self.entity_description.value_fn(self.get_dateinfo(), self.make_zmanim) return self.entity_description.value_fn(
self.get_dateinfo(), self.coordinator.make_zmanim
)

View File

@@ -3,6 +3,15 @@
dict({ dict({
'data': dict({ 'data': dict({
'candle_lighting_offset': 40, 'candle_lighting_offset': 40,
'dateinfo': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'diaspora': False, 'diaspora': False,
'havdalah_offset': 0, 'havdalah_offset': 0,
'language': 'en', 'language': 'en',
@@ -17,33 +26,22 @@
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')", 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
}), }),
}), }),
'results': dict({ 'zmanim': dict({
'dateinfo': dict({ 'candle_lighting_offset': 40,
'date': dict({ 'date': dict({
'day': 21, '__type': "<class 'freezegun.api.FakeDate'>",
'month': 10, 'isoformat': '2025-05-19',
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}), }),
'zmanim': dict({ 'havdalah_offset': 0,
'candle_lighting_offset': 40, 'location': dict({
'date': dict({ 'altitude': '**REDACTED**',
'__type': "<class 'freezegun.api.FakeDate'>", 'diaspora': False,
'isoformat': '2025-05-19', 'latitude': '**REDACTED**',
}), 'longitude': '**REDACTED**',
'havdalah_offset': 0, 'name': 'test home',
'location': dict({ 'timezone': dict({
'altitude': '**REDACTED**', '__type': "<class 'zoneinfo.ZoneInfo'>",
'diaspora': False, 'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
'name': 'test home',
'timezone': dict({
'__type': "<class 'zoneinfo.ZoneInfo'>",
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
}),
}), }),
}), }),
}), }),
@@ -59,6 +57,15 @@
dict({ dict({
'data': dict({ 'data': dict({
'candle_lighting_offset': 18, 'candle_lighting_offset': 18,
'dateinfo': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': True,
'nusach': 'sephardi',
}),
'diaspora': True, 'diaspora': True,
'havdalah_offset': 0, 'havdalah_offset': 0,
'language': 'en', 'language': 'en',
@@ -73,33 +80,22 @@
'repr': "zoneinfo.ZoneInfo(key='America/New_York')", 'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
}), }),
}), }),
'results': dict({ 'zmanim': dict({
'dateinfo': dict({ 'candle_lighting_offset': 18,
'date': dict({ 'date': dict({
'day': 21, '__type': "<class 'freezegun.api.FakeDate'>",
'month': 10, 'isoformat': '2025-05-19',
'year': 5785,
}),
'diaspora': True,
'nusach': 'sephardi',
}), }),
'zmanim': dict({ 'havdalah_offset': 0,
'candle_lighting_offset': 18, 'location': dict({
'date': dict({ 'altitude': '**REDACTED**',
'__type': "<class 'freezegun.api.FakeDate'>", 'diaspora': True,
'isoformat': '2025-05-19', 'latitude': '**REDACTED**',
}), 'longitude': '**REDACTED**',
'havdalah_offset': 0, 'name': 'test home',
'location': dict({ 'timezone': dict({
'altitude': '**REDACTED**', '__type': "<class 'zoneinfo.ZoneInfo'>",
'diaspora': True, 'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
'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({ dict({
'data': dict({ 'data': dict({
'candle_lighting_offset': 18, 'candle_lighting_offset': 18,
'dateinfo': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'diaspora': False, 'diaspora': False,
'havdalah_offset': 0, 'havdalah_offset': 0,
'language': 'en', 'language': 'en',
@@ -129,33 +134,22 @@
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')", 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
}), }),
}), }),
'results': dict({ 'zmanim': dict({
'dateinfo': dict({ 'candle_lighting_offset': 18,
'date': dict({ 'date': dict({
'day': 21, '__type': "<class 'freezegun.api.FakeDate'>",
'month': 10, 'isoformat': '2025-05-19',
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}), }),
'zmanim': dict({ 'havdalah_offset': 0,
'candle_lighting_offset': 18, 'location': dict({
'date': dict({ 'altitude': '**REDACTED**',
'__type': "<class 'freezegun.api.FakeDate'>", 'diaspora': False,
'isoformat': '2025-05-19', 'latitude': '**REDACTED**',
}), 'longitude': '**REDACTED**',
'havdalah_offset': 0, 'name': 'test home',
'location': dict({ 'timezone': dict({
'altitude': '**REDACTED**', '__type': "<class 'zoneinfo.ZoneInfo'>",
'diaspora': False, 'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
'latitude': '**REDACTED**',
'longitude': '**REDACTED**',
'name': 'test home',
'timezone': dict({
'__type': "<class 'zoneinfo.ZoneInfo'>",
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
}),
}), }),
}), }),
}), }),