Jewish calendar: move value calculation to entity description (1/3) (#144272)

* Move make_zmanim() method to entity

* Move enum values to setup

* Create a Jewish Calendar Sensor Description

* Hold a single variable for the runtime data in the entity

* Move value calculation to sensor description

* Use a base class to keep timestamp sensor inheritance

* Move attr to entity description as well

* Move options to entity description as well

* Fix tests after merge

* Put multiline in parentheses

* Fix diagnostics tests
This commit is contained in:
Tsvi Mostovicz 2025-05-20 20:01:24 +03:00 committed by GitHub
parent 734d6cd247
commit b71870aba3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 322 additions and 149 deletions

View File

@ -82,18 +82,9 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
@property
def is_on(self) -> bool:
"""Return true if sensor is on."""
zmanim = self._get_zmanim()
zmanim = self.make_zmanim(dt.date.today())
return self.entity_description.is_on(zmanim, dt_util.now())
def _get_zmanim(self) -> Zmanim:
"""Return the Zmanim object for now()."""
return Zmanim(
date=dt.date.today(),
location=self._location,
candle_lighting_offset=self._candle_lighting_offset,
havdalah_offset=self._havdalah_offset,
)
async def async_added_to_hass(self) -> None:
"""Run when entity about to be added to hass."""
await super().async_added_to_hass()
@ -116,7 +107,7 @@ class JewishCalendarBinarySensor(JewishCalendarEntity, BinarySensorEntity):
def _schedule_update(self) -> None:
"""Schedule the next update of the sensor."""
now = dt_util.now()
zmanim = self._get_zmanim()
zmanim = self.make_zmanim(dt.date.today())
update = zmanim.netz_hachama.local + dt.timedelta(days=1)
candle_lighting = zmanim.candle_lighting
if candle_lighting is not None and now < candle_lighting < update:

View File

@ -1,8 +1,9 @@
"""Entity representing a Jewish Calendar sensor."""
from dataclasses import dataclass
import datetime as dt
from hdate import Location
from hdate import HDateInfo, Location, Zmanim
from hdate.translator import Language, set_language
from homeassistant.config_entries import ConfigEntry
@ -14,6 +15,16 @@ from .const import DOMAIN
type JewishCalendarConfigEntry = ConfigEntry[JewishCalendarData]
@dataclass
class JewishCalendarDataResults:
"""Jewish Calendar results dataclass."""
daytime_date: HDateInfo
after_shkia_date: HDateInfo
after_tzais_date: HDateInfo
zmanim: Zmanim
@dataclass
class JewishCalendarData:
"""Jewish Calendar runtime dataclass."""
@ -23,6 +34,7 @@ class JewishCalendarData:
location: Location
candle_lighting_offset: int
havdalah_offset: int
results: JewishCalendarDataResults | None = None
class JewishCalendarEntity(Entity):
@ -42,9 +54,14 @@ class JewishCalendarEntity(Entity):
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, config_entry.entry_id)},
)
data = config_entry.runtime_data
self._location = data.location
self._candle_lighting_offset = data.candle_lighting_offset
self._havdalah_offset = data.havdalah_offset
self._diaspora = data.diaspora
set_language(data.language)
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,
)

View File

@ -2,9 +2,10 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import datetime as dt
import logging
from typing import Any
from hdate import HDateInfo, Zmanim
from hdate.holidays import HolidayDatabase
@ -21,124 +22,192 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sun import get_astral_event_date
from homeassistant.util import dt as dt_util
from .entity import JewishCalendarConfigEntry, JewishCalendarEntity
from .entity import (
JewishCalendarConfigEntry,
JewishCalendarDataResults,
JewishCalendarEntity,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
INFO_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
@dataclass(frozen=True, kw_only=True)
class JewishCalendarBaseSensorDescription(SensorEntityDescription):
"""Base class describing Jewish Calendar sensor entities."""
value_fn: Callable | None
@dataclass(frozen=True, kw_only=True)
class JewishCalendarSensorDescription(JewishCalendarBaseSensorDescription):
"""Class describing Jewish Calendar sensor entities."""
value_fn: Callable[[JewishCalendarDataResults], str | int]
attr_fn: Callable[[JewishCalendarDataResults], dict[str, str]] | None = None
options_fn: Callable[[bool], list[str]] | None = None
@dataclass(frozen=True, kw_only=True)
class JewishCalendarTimestampSensorDescription(JewishCalendarBaseSensorDescription):
"""Class describing Jewish Calendar sensor timestamp entities."""
value_fn: (
Callable[[HDateInfo, Callable[[dt.date], Zmanim]], dt.datetime | None] | None
) = None
INFO_SENSORS: tuple[JewishCalendarSensorDescription, ...] = (
JewishCalendarSensorDescription(
key="date",
translation_key="hebrew_date",
value_fn=lambda results: str(results.after_shkia_date.hdate),
attr_fn=lambda results: {
"hebrew_year": str(results.after_shkia_date.hdate.year),
"hebrew_month_name": str(results.after_shkia_date.hdate.month),
"hebrew_day": str(results.after_shkia_date.hdate.day),
},
),
SensorEntityDescription(
JewishCalendarSensorDescription(
key="weekly_portion",
translation_key="weekly_portion",
device_class=SensorDeviceClass.ENUM,
options_fn=lambda _: [str(p) for p in Parasha],
value_fn=lambda results: str(results.after_tzais_date.upcoming_shabbat.parasha),
),
SensorEntityDescription(
JewishCalendarSensorDescription(
key="holiday",
translation_key="holiday",
device_class=SensorDeviceClass.ENUM,
options_fn=lambda diaspora: HolidayDatabase(diaspora).get_all_names(),
value_fn=lambda results: ", ".join(
str(holiday) for holiday in results.after_shkia_date.holidays
),
attr_fn=lambda results: {
"id": ", ".join(
holiday.name for holiday in results.after_shkia_date.holidays
),
"type": ", ".join(
dict.fromkeys(
_holiday.type.name for _holiday in results.after_shkia_date.holidays
)
),
},
),
SensorEntityDescription(
JewishCalendarSensorDescription(
key="omer_count",
translation_key="omer_count",
entity_registry_enabled_default=False,
value_fn=lambda results: (
results.after_shkia_date.omer.total_days
if results.after_shkia_date.omer
else 0
),
),
SensorEntityDescription(
JewishCalendarSensorDescription(
key="daf_yomi",
translation_key="daf_yomi",
entity_registry_enabled_default=False,
value_fn=lambda results: str(results.daytime_date.daf_yomi),
),
)
TIME_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
TIME_SENSORS: tuple[JewishCalendarTimestampSensorDescription, ...] = (
JewishCalendarTimestampSensorDescription(
key="alot_hashachar",
translation_key="alot_hashachar",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="talit_and_tefillin",
translation_key="talit_and_tefillin",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="netz_hachama",
translation_key="netz_hachama",
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="sof_zman_shema_gra",
translation_key="sof_zman_shema_gra",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="sof_zman_shema_mga",
translation_key="sof_zman_shema_mga",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="sof_zman_tfilla_gra",
translation_key="sof_zman_tfilla_gra",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="sof_zman_tfilla_mga",
translation_key="sof_zman_tfilla_mga",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="chatzot_hayom",
translation_key="chatzot_hayom",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="mincha_gedola",
translation_key="mincha_gedola",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="mincha_ketana",
translation_key="mincha_ketana",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="plag_hamincha",
translation_key="plag_hamincha",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="shkia",
translation_key="shkia",
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="tset_hakohavim_tsom",
translation_key="tset_hakohavim_tsom",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="tset_hakohavim_shabbat",
translation_key="tset_hakohavim_shabbat",
entity_registry_enabled_default=False,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="upcoming_shabbat_candle_lighting",
translation_key="upcoming_shabbat_candle_lighting",
entity_registry_enabled_default=False,
value_fn=lambda at_date, mz: mz(
at_date.upcoming_shabbat.previous_day.gdate
).candle_lighting,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="upcoming_shabbat_havdalah",
translation_key="upcoming_shabbat_havdalah",
entity_registry_enabled_default=False,
value_fn=lambda at_date, mz: mz(at_date.upcoming_shabbat.gdate).havdalah,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="upcoming_candle_lighting",
translation_key="upcoming_candle_lighting",
value_fn=lambda at_date, mz: mz(
at_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate
).candle_lighting,
),
SensorEntityDescription(
JewishCalendarTimestampSensorDescription(
key="upcoming_havdalah",
translation_key="upcoming_havdalah",
value_fn=lambda at_date, mz: mz(
at_date.upcoming_shabbat_or_yom_tov.last_day.gdate
).havdalah,
),
)
@ -149,40 +218,30 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Jewish calendar sensors ."""
sensors = [
sensors: list[JewishCalendarBaseSensor] = [
JewishCalendarSensor(config_entry, description) for description in INFO_SENSORS
]
sensors.extend(
JewishCalendarTimeSensor(config_entry, description)
for description in TIME_SENSORS
)
async_add_entities(sensors)
class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
"""Representation of an Jewish calendar sensor."""
class JewishCalendarBaseSensor(JewishCalendarEntity, SensorEntity):
"""Base class for Jewish calendar sensors."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def __init__(
self,
config_entry: JewishCalendarConfigEntry,
description: SensorEntityDescription,
) -> None:
"""Initialize the Jewish calendar sensor."""
super().__init__(config_entry, description)
self._attrs: dict[str, str] = {}
async def async_added_to_hass(self) -> None:
"""Call when entity is added to hass."""
await super().async_added_to_hass()
await self.async_update()
await self.async_update_data()
async def async_update(self) -> None:
async def async_update_data(self) -> None:
"""Update the state of the sensor."""
now = dt_util.now()
_LOGGER.debug("Now: %s Location: %r", now, self._location)
_LOGGER.debug("Now: %s Location: %r", now, self.data.location)
today = now.date()
event_date = get_astral_event_date(self.hass, SUN_EVENT_SUNSET, today)
@ -195,7 +254,7 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
_LOGGER.debug("Now: %s Sunset: %s", now, sunset)
daytime_date = HDateInfo(today, diaspora=self._diaspora)
daytime_date = HDateInfo(today, diaspora=self.data.diaspora)
# The Jewish day starts after darkness (called "tzais") and finishes at
# sunset ("shkia"). The time in between is a gray area
@ -214,95 +273,57 @@ class JewishCalendarSensor(JewishCalendarEntity, SensorEntity):
if today_times.havdalah and now > today_times.havdalah:
after_tzais_date = daytime_date.next_day
self._attr_native_value = self.get_state(
daytime_date, after_shkia_date, after_tzais_date
)
_LOGGER.debug(
"New value for %s: %s", self.entity_description.key, self._attr_native_value
self.data.results = JewishCalendarDataResults(
daytime_date, after_shkia_date, after_tzais_date, today_times
)
def make_zmanim(self, date: dt.date) -> Zmanim:
"""Create a Zmanim object."""
return Zmanim(
date=date,
location=self._location,
candle_lighting_offset=self._candle_lighting_offset,
havdalah_offset=self._havdalah_offset,
)
class JewishCalendarSensor(JewishCalendarBaseSensor):
"""Representation of an Jewish calendar sensor."""
entity_description: JewishCalendarSensorDescription
def __init__(
self,
config_entry: JewishCalendarConfigEntry,
description: SensorEntityDescription,
) -> None:
"""Initialize the Jewish calendar sensor."""
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)
@property
def native_value(self) -> str | int | dt.datetime | None:
"""Return the state of the sensor."""
if self.data.results is None:
return None
return self.entity_description.value_fn(self.data.results)
@property
def extra_state_attributes(self) -> dict[str, str]:
"""Return the state attributes."""
return self._attrs
def get_state(
self,
daytime_date: HDateInfo,
after_shkia_date: HDateInfo,
after_tzais_date: HDateInfo,
) -> Any | None:
"""For a given type of sensor, return the state."""
# Terminology note: by convention in py-libhdate library, "upcoming"
# refers to "current" or "upcoming" dates.
if self.entity_description.key == "date":
hdate = after_shkia_date.hdate
self._attrs = {
"hebrew_year": str(hdate.year),
"hebrew_month_name": str(hdate.month),
"hebrew_day": str(hdate.day),
}
return after_shkia_date.hdate
if self.entity_description.key == "weekly_portion":
self._attr_options = [str(p) for p in Parasha]
# Compute the weekly portion based on the upcoming shabbat.
return str(after_tzais_date.upcoming_shabbat.parasha)
if self.entity_description.key == "holiday":
_holidays = after_shkia_date.holidays
_id = ", ".join(holiday.name for holiday in _holidays)
_type = ", ".join(
dict.fromkeys(_holiday.type.name for _holiday in _holidays)
)
self._attrs = {"id": _id, "type": _type}
self._attr_options = HolidayDatabase(self._diaspora).get_all_names()
return ", ".join(str(holiday) for holiday in _holidays) if _holidays else ""
if self.entity_description.key == "omer_count":
return after_shkia_date.omer.total_days if after_shkia_date.omer else 0
if self.entity_description.key == "daf_yomi":
return daytime_date.daf_yomi
return None
if self.data.results is None:
return {}
if self.entity_description.attr_fn is not None:
return self.entity_description.attr_fn(self.data.results)
return {}
class JewishCalendarTimeSensor(JewishCalendarSensor):
class JewishCalendarTimeSensor(JewishCalendarBaseSensor):
"""Implement attributes for sensors returning times."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
entity_description: JewishCalendarTimestampSensorDescription
def get_state(
self,
daytime_date: HDateInfo,
after_shkia_date: HDateInfo,
after_tzais_date: HDateInfo,
) -> Any | None:
"""For a given type of sensor, return the state."""
if self.entity_description.key == "upcoming_shabbat_candle_lighting":
times = self.make_zmanim(
after_tzais_date.upcoming_shabbat.previous_day.gdate
)
return times.candle_lighting
if self.entity_description.key == "upcoming_candle_lighting":
times = self.make_zmanim(
after_tzais_date.upcoming_shabbat_or_yom_tov.first_day.previous_day.gdate
)
return times.candle_lighting
if self.entity_description.key == "upcoming_shabbat_havdalah":
times = self.make_zmanim(after_tzais_date.upcoming_shabbat.gdate)
return times.havdalah
if self.entity_description.key == "upcoming_havdalah":
times = self.make_zmanim(
after_tzais_date.upcoming_shabbat_or_yom_tov.last_day.gdate
)
return times.havdalah
times = self.make_zmanim(dt_util.now().date())
return times.zmanim[self.entity_description.key].local
@property
def native_value(self) -> dt.datetime | None:
"""Return the state of the sensor."""
if self.data.results is None:
return None
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.data.results.after_tzais_date, self.make_zmanim
)

View File

@ -1,5 +1,5 @@
# serializer version: 1
# name: test_diagnostics[Jerusalem]
# name: test_diagnostics[test_time0-Jerusalem]
dict({
'data': dict({
'candle_lighting_offset': 40,
@ -17,6 +17,54 @@
'repr': "zoneinfo.ZoneInfo(key='Asia/Jerusalem')",
}),
}),
'results': dict({
'after_shkia_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'after_tzais_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'daytime_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'zmanim': dict({
'candle_lighting_offset': 40,
'date': dict({
'__type': "<class 'freezegun.api.FakeDate'>",
'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')",
}),
}),
}),
}),
}),
'entry_data': dict({
'diaspora': False,
@ -25,7 +73,7 @@
}),
})
# ---
# name: test_diagnostics[New York]
# name: test_diagnostics[test_time0-New York]
dict({
'data': dict({
'candle_lighting_offset': 18,
@ -43,6 +91,54 @@
'repr': "zoneinfo.ZoneInfo(key='America/New_York')",
}),
}),
'results': dict({
'after_shkia_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': True,
'nusach': 'sephardi',
}),
'after_tzais_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': True,
'nusach': 'sephardi',
}),
'daytime_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': True,
'nusach': 'sephardi',
}),
'zmanim': dict({
'candle_lighting_offset': 18,
'date': dict({
'__type': "<class 'freezegun.api.FakeDate'>",
'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')",
}),
}),
}),
}),
}),
'entry_data': dict({
'diaspora': True,
@ -51,7 +147,7 @@
}),
})
# ---
# name: test_diagnostics[None]
# name: test_diagnostics[test_time0-None]
dict({
'data': dict({
'candle_lighting_offset': 18,
@ -69,6 +165,54 @@
'repr': "zoneinfo.ZoneInfo(key='US/Pacific')",
}),
}),
'results': dict({
'after_shkia_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'after_tzais_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'daytime_date': dict({
'date': dict({
'day': 21,
'month': 10,
'year': 5785,
}),
'diaspora': False,
'nusach': 'sephardi',
}),
'zmanim': dict({
'candle_lighting_offset': 18,
'date': dict({
'__type': "<class 'freezegun.api.FakeDate'>",
'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')",
}),
}),
}),
}),
}),
'entry_data': dict({
'language': 'en',

View File

@ -1,5 +1,7 @@
"""Tests for the diagnostics data provided by the Jewish Calendar integration."""
import datetime as dt
import pytest
from syrupy.assertion import SnapshotAssertion
@ -13,6 +15,8 @@ from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize(
("location_data"), ["Jerusalem", "New York", None], indirect=True
)
@pytest.mark.parametrize("test_time", [dt.datetime(2025, 5, 19)], indirect=True)
@pytest.mark.usefixtures("setup_at_time")
async def test_diagnostics(
hass: HomeAssistant,
config_entry: MockConfigEntry,
@ -20,10 +24,6 @@ async def test_diagnostics(
snapshot: SnapshotAssertion,
) -> None:
"""Test diagnostics with different locations."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
diagnostics_data = await get_diagnostics_for_config_entry(
hass, hass_client, config_entry
)