diff --git a/custom_components/garbage_collection/config_flow.py b/custom_components/garbage_collection/config_flow.py index d8cd8c3..9569545 100644 --- a/custom_components/garbage_collection/config_flow.py +++ b/custom_components/garbage_collection/config_flow.py @@ -95,9 +95,9 @@ async def general_config_schema( const.DEFAULT_HOLIDAY_IN_WEEK_MOVE, ): bool, optional( - const.CONF_HOLIDAY_COUNTRY, + const.CONF_HOLIDAY_DATES, handler.options, - const.DEFAULT_HOLIDAY_COUNTRY, + const.DEFAULT_HOLIDAY_DATES, ): selector.TextSelector(), } ) @@ -135,9 +135,9 @@ async def general_options_schema( const.DEFAULT_HOLIDAY_IN_WEEK_MOVE, ): bool, optional( - const.CONF_HOLIDAY_COUNTRY, + const.CONF_HOLIDAY_DATES, handler.options, - const.DEFAULT_HOLIDAY_COUNTRY, + const.DEFAULT_HOLIDAY_DATES, ): selector.TextSelector(), } ) diff --git a/custom_components/garbage_collection/const.py b/custom_components/garbage_collection/const.py index a2aca1d..8e6dcb0 100644 --- a/custom_components/garbage_collection/const.py +++ b/custom_components/garbage_collection/const.py @@ -44,7 +44,7 @@ CONF_SENSORS = "sensors" CONF_VERBOSE_FORMAT = "verbose_format" CONF_DATE_FORMAT = "date_format" CONF_MOVE_COUNTRY_HOLIDAYS = "move_country_holidays" -CONF_HOLIDAY_COUNTRY = "holiday_country" +CONF_HOLIDAY_DATES = "holiday_dates" # Defaults DEFAULT_NAME = DOMAIN @@ -55,7 +55,7 @@ DEFAULT_PERIOD = 1 DEFAULT_FIRST_WEEK = 1 DEFAULT_VERBOSE_STATE = False DEFAULT_HOLIDAY_IN_WEEK_MOVE = False -DEFAULT_HOLIDAY_COUNTRY = "FR" +DEFAULT_HOLIDAY_DATES = "01-01,05-01,12-25" DEFAULT_DATE_FORMAT = "%d-%b-%Y" DEFAULT_VERBOSE_FORMAT = "on {date}, in {days} days" diff --git a/custom_components/garbage_collection/manifest.json b/custom_components/garbage_collection/manifest.json index 9514084..fa6dcfa 100644 --- a/custom_components/garbage_collection/manifest.json +++ b/custom_components/garbage_collection/manifest.json @@ -11,8 +11,7 @@ "iot_class": "calculated", "issue_tracker": "https://github.com/bruxy70/Garbage-Collection/issues", "requirements": [ - "python-dateutil>=2.8.2", - "holidays>=0.40" + "python-dateutil>=2.8.2" ], - "version": "3.21.1" + "version": "3.21.3" } \ No newline at end of file diff --git a/custom_components/garbage_collection/sensor.py b/custom_components/garbage_collection/sensor.py index bbc1adc..f7deeba 100644 --- a/custom_components/garbage_collection/sensor.py +++ b/custom_components/garbage_collection/sensor.py @@ -5,8 +5,8 @@ import logging from datetime import date, datetime, time, timedelta from typing import Any, Dict, Generator +from dateutil.easter import easter from dateutil.relativedelta import relativedelta -import holidays from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( ATTR_DEVICE_CLASS, @@ -71,8 +71,8 @@ class GarbageCollection(RestoreEntity): "_days", "_first_month", "_hidden", - "_holiday_country", - "_holidays_calendar", + "_holiday_dates", + "_move_holidays", "_icon_normal", "_icon_today", "_icon_tomorrow", @@ -126,23 +126,25 @@ class GarbageCollection(RestoreEntity): self._verbose_format = config.get( const.CONF_VERBOSE_FORMAT, const.DEFAULT_VERBOSE_FORMAT ) - self._holiday_country = config.get( - const.CONF_HOLIDAY_COUNTRY, const.DEFAULT_HOLIDAY_COUNTRY - ) - if config.get(const.CONF_MOVE_COUNTRY_HOLIDAYS, False): - try: - self._holidays_calendar = holidays.country_holidays( - self._holiday_country - ) - except NotImplementedError: - _LOGGER.error( - "(%s) Unknown country code for holidays: %s", - self._attr_name, - self._holiday_country, - ) - self._holidays_calendar = None - else: - self._holidays_calendar = None + self._holiday_dates: set[tuple[int, int]] = set() + self._move_holidays = bool(config.get(const.CONF_MOVE_COUNTRY_HOLIDAYS, False)) + if self._move_holidays: + raw_dates = config.get( + const.CONF_HOLIDAY_DATES, const.DEFAULT_HOLIDAY_DATES + ) + for chunk in raw_dates.split(","): + chunk = chunk.strip() + if not chunk: + continue + try: + month_str, day_str = chunk.split("-") + self._holiday_dates.add((int(month_str), int(day_str))) + except ValueError: + _LOGGER.error( + "(%s) Invalid holiday date '%s', expected format MM-DD", + self._attr_name, + chunk, + ) self._collection_dates: list[date] = [] self._next_date: date | None = None self._last_updated: datetime | None = None @@ -378,31 +380,40 @@ class GarbageCollection(RestoreEntity): yield next_date first_date = next_date + relativedelta(days=1) # look from the next day - def _shift_for_holiday(self, collection_date: date) -> date: - """Move the date forward if a public holiday falls on it or earlier that week. + def _is_exception_date(self, day: date) -> bool: + """Check if a date matches a configured fixed exception, or a movable one. - First check Monday..collection_date (inclusive) for a holiday: if any day in - that range is a holiday, move forward by one day. Then keep moving forward, - one day at a time, while the new candidate day is itself a holiday. + Fixed exceptions come from the configured MM-DD list (e.g. Christmas, + New Year, Labour Day for collectors that only shift on those). + Movable exceptions are always checked too: Easter Monday and Whit + Monday (Pentecost Monday) are the only French public holidays that + ever fall on a Monday, which only matters for Monday-based schedules + and is harmless to check for any other day. """ - if self._holidays_calendar is None: + if (day.month, day.day) in self._holiday_dates: + return True + easter_sunday = easter(day.year) + easter_monday = easter_sunday + timedelta(days=1) + pentecost_monday = easter_sunday + timedelta(days=50) + return day in (easter_monday, pentecost_monday) + + def _shift_for_holiday(self, collection_date: date) -> date: + """Move the date forward by one day for each matching exception date. + + Cascades forward if the new day also matches an exception date. + """ + if not self._move_holidays: return collection_date - monday = collection_date - timedelta(days=collection_date.weekday()) - week_has_holiday = any( - (monday + timedelta(days=i)) in self._holidays_calendar - for i in range(collection_date.weekday() + 1) - ) - if not week_has_holiday: - return collection_date - shifted = collection_date + timedelta(days=1) - while shifted in self._holidays_calendar: + shifted = collection_date + while self._is_exception_date(shifted): shifted = shifted + timedelta(days=1) - _LOGGER.debug( - "(%s) %s shifted to %s because of a public holiday", - self._attr_name, - collection_date, - shifted, - ) + if shifted != collection_date: + _LOGGER.debug( + "(%s) %s shifted to %s because of a configured exception date", + self._attr_name, + collection_date, + shifted, + ) return shifted async def _async_load_collection_dates(self) -> None: diff --git a/custom_components/garbage_collection/translations/en.json b/custom_components/garbage_collection/translations/en.json index 57b97f2..68f7ee5 100644 --- a/custom_components/garbage_collection/translations/en.json +++ b/custom_components/garbage_collection/translations/en.json @@ -15,7 +15,7 @@ "expire_after": "Expire after (HH:MM) - optional", "verbose_state": "Verbose state (text, instead of number)", "move_country_holidays": "Shift collection if a public holiday falls in the week", - "holiday_country": "Country code for public holidays (e.g. FR)" + "holiday_dates": "Dates that shift collection by one day (format MM-DD, comma separated)" } }, "detail": { @@ -69,7 +69,7 @@ "expire_after": "Expire after (HH:MM) - optional", "verbose_state": "Verbose state (text, instead of number)", "move_country_holidays": "Shift collection if a public holiday falls in the week", - "holiday_country": "Country code for public holidays (e.g. FR)" + "holiday_dates": "Dates that shift collection by one day (format MM-DD, comma separated)" } }, "detail": { diff --git a/custom_components/garbage_collection/translations/fr.json b/custom_components/garbage_collection/translations/fr.json index addd89a..464fbab 100644 --- a/custom_components/garbage_collection/translations/fr.json +++ b/custom_components/garbage_collection/translations/fr.json @@ -16,7 +16,7 @@ "expire_after": "Expire après (HH:MM)", "verbose_state": "Etat verbeux (texte, au lieu du chiffre)", "move_country_holidays": "Décaler la collecte si jour férié dans la semaine", - "holiday_country": "Code pays pour les jours fériés (ex: FR)" + "holiday_dates": "Dates qui décalent la collecte d'un jour (format MM-JJ, séparées par des virgules)" } }, "detail": { @@ -71,7 +71,7 @@ "expire_after": "Expire après (HH:MM)", "verbose_state": "Etat verbeux (texte, au lieu du chiffre)", "move_country_holidays": "Décaler la collecte si jour férié dans la semaine", - "holiday_country": "Code pays pour les jours fériés (ex: FR)" + "holiday_dates": "Dates qui décalent la collecte d'un jour (format MM-JJ, séparées par des virgules)" } }, "detail": { diff --git a/garbage_collection_fork_morgane_v3.zip b/garbage_collection_fork_morgane_v3.zip deleted file mode 100644 index 08079ca..0000000 Binary files a/garbage_collection_fork_morgane_v3.zip and /dev/null differ