Décalage jours fériés mobiles + config helpers

This commit is contained in:
2026-06-17 12:45:35 +02:00
parent 6da3332a73
commit 35d6cf71dd
7 changed files with 64 additions and 54 deletions
@@ -95,9 +95,9 @@ async def general_config_schema(
const.DEFAULT_HOLIDAY_IN_WEEK_MOVE, const.DEFAULT_HOLIDAY_IN_WEEK_MOVE,
): bool, ): bool,
optional( optional(
const.CONF_HOLIDAY_COUNTRY, const.CONF_HOLIDAY_DATES,
handler.options, handler.options,
const.DEFAULT_HOLIDAY_COUNTRY, const.DEFAULT_HOLIDAY_DATES,
): selector.TextSelector(), ): selector.TextSelector(),
} }
) )
@@ -135,9 +135,9 @@ async def general_options_schema(
const.DEFAULT_HOLIDAY_IN_WEEK_MOVE, const.DEFAULT_HOLIDAY_IN_WEEK_MOVE,
): bool, ): bool,
optional( optional(
const.CONF_HOLIDAY_COUNTRY, const.CONF_HOLIDAY_DATES,
handler.options, handler.options,
const.DEFAULT_HOLIDAY_COUNTRY, const.DEFAULT_HOLIDAY_DATES,
): selector.TextSelector(), ): selector.TextSelector(),
} }
) )
@@ -44,7 +44,7 @@ CONF_SENSORS = "sensors"
CONF_VERBOSE_FORMAT = "verbose_format" CONF_VERBOSE_FORMAT = "verbose_format"
CONF_DATE_FORMAT = "date_format" CONF_DATE_FORMAT = "date_format"
CONF_MOVE_COUNTRY_HOLIDAYS = "move_country_holidays" CONF_MOVE_COUNTRY_HOLIDAYS = "move_country_holidays"
CONF_HOLIDAY_COUNTRY = "holiday_country" CONF_HOLIDAY_DATES = "holiday_dates"
# Defaults # Defaults
DEFAULT_NAME = DOMAIN DEFAULT_NAME = DOMAIN
@@ -55,7 +55,7 @@ DEFAULT_PERIOD = 1
DEFAULT_FIRST_WEEK = 1 DEFAULT_FIRST_WEEK = 1
DEFAULT_VERBOSE_STATE = False DEFAULT_VERBOSE_STATE = False
DEFAULT_HOLIDAY_IN_WEEK_MOVE = 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_DATE_FORMAT = "%d-%b-%Y"
DEFAULT_VERBOSE_FORMAT = "on {date}, in {days} days" DEFAULT_VERBOSE_FORMAT = "on {date}, in {days} days"
@@ -11,8 +11,7 @@
"iot_class": "calculated", "iot_class": "calculated",
"issue_tracker": "https://github.com/bruxy70/Garbage-Collection/issues", "issue_tracker": "https://github.com/bruxy70/Garbage-Collection/issues",
"requirements": [ "requirements": [
"python-dateutil>=2.8.2", "python-dateutil>=2.8.2"
"holidays>=0.40"
], ],
"version": "3.21.1" "version": "3.21.3"
} }
+52 -41
View File
@@ -5,8 +5,8 @@ import logging
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from typing import Any, Dict, Generator from typing import Any, Dict, Generator
from dateutil.easter import easter
from dateutil.relativedelta import relativedelta from dateutil.relativedelta import relativedelta
import holidays
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_DEVICE_CLASS, ATTR_DEVICE_CLASS,
@@ -71,8 +71,8 @@ class GarbageCollection(RestoreEntity):
"_days", "_days",
"_first_month", "_first_month",
"_hidden", "_hidden",
"_holiday_country", "_holiday_dates",
"_holidays_calendar", "_move_holidays",
"_icon_normal", "_icon_normal",
"_icon_today", "_icon_today",
"_icon_tomorrow", "_icon_tomorrow",
@@ -126,23 +126,25 @@ class GarbageCollection(RestoreEntity):
self._verbose_format = config.get( self._verbose_format = config.get(
const.CONF_VERBOSE_FORMAT, const.DEFAULT_VERBOSE_FORMAT const.CONF_VERBOSE_FORMAT, const.DEFAULT_VERBOSE_FORMAT
) )
self._holiday_country = config.get( self._holiday_dates: set[tuple[int, int]] = set()
const.CONF_HOLIDAY_COUNTRY, const.DEFAULT_HOLIDAY_COUNTRY self._move_holidays = bool(config.get(const.CONF_MOVE_COUNTRY_HOLIDAYS, False))
) if self._move_holidays:
if config.get(const.CONF_MOVE_COUNTRY_HOLIDAYS, False): raw_dates = config.get(
try: const.CONF_HOLIDAY_DATES, const.DEFAULT_HOLIDAY_DATES
self._holidays_calendar = holidays.country_holidays( )
self._holiday_country for chunk in raw_dates.split(","):
) chunk = chunk.strip()
except NotImplementedError: if not chunk:
_LOGGER.error( continue
"(%s) Unknown country code for holidays: %s", try:
self._attr_name, month_str, day_str = chunk.split("-")
self._holiday_country, self._holiday_dates.add((int(month_str), int(day_str)))
) except ValueError:
self._holidays_calendar = None _LOGGER.error(
else: "(%s) Invalid holiday date '%s', expected format MM-DD",
self._holidays_calendar = None self._attr_name,
chunk,
)
self._collection_dates: list[date] = [] self._collection_dates: list[date] = []
self._next_date: date | None = None self._next_date: date | None = None
self._last_updated: datetime | None = None self._last_updated: datetime | None = None
@@ -378,31 +380,40 @@ class GarbageCollection(RestoreEntity):
yield next_date yield next_date
first_date = next_date + relativedelta(days=1) # look from the next day first_date = next_date + relativedelta(days=1) # look from the next day
def _shift_for_holiday(self, collection_date: date) -> date: def _is_exception_date(self, day: date) -> bool:
"""Move the date forward if a public holiday falls on it or earlier that week. """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 Fixed exceptions come from the configured MM-DD list (e.g. Christmas,
that range is a holiday, move forward by one day. Then keep moving forward, New Year, Labour Day for collectors that only shift on those).
one day at a time, while the new candidate day is itself a holiday. 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 return collection_date
monday = collection_date - timedelta(days=collection_date.weekday()) shifted = collection_date
week_has_holiday = any( while self._is_exception_date(shifted):
(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 = shifted + timedelta(days=1) shifted = shifted + timedelta(days=1)
_LOGGER.debug( if shifted != collection_date:
"(%s) %s shifted to %s because of a public holiday", _LOGGER.debug(
self._attr_name, "(%s) %s shifted to %s because of a configured exception date",
collection_date, self._attr_name,
shifted, collection_date,
) shifted,
)
return shifted return shifted
async def _async_load_collection_dates(self) -> None: async def _async_load_collection_dates(self) -> None:
@@ -15,7 +15,7 @@
"expire_after": "Expire after (HH:MM) - optional", "expire_after": "Expire after (HH:MM) - optional",
"verbose_state": "Verbose state (text, instead of number)", "verbose_state": "Verbose state (text, instead of number)",
"move_country_holidays": "Shift collection if a public holiday falls in the week", "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": { "detail": {
@@ -69,7 +69,7 @@
"expire_after": "Expire after (HH:MM) - optional", "expire_after": "Expire after (HH:MM) - optional",
"verbose_state": "Verbose state (text, instead of number)", "verbose_state": "Verbose state (text, instead of number)",
"move_country_holidays": "Shift collection if a public holiday falls in the week", "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": { "detail": {
@@ -16,7 +16,7 @@
"expire_after": "Expire après (HH:MM)", "expire_after": "Expire après (HH:MM)",
"verbose_state": "Etat verbeux (texte, au lieu du chiffre)", "verbose_state": "Etat verbeux (texte, au lieu du chiffre)",
"move_country_holidays": "Décaler la collecte si jour férié dans la semaine", "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": { "detail": {
@@ -71,7 +71,7 @@
"expire_after": "Expire après (HH:MM)", "expire_after": "Expire après (HH:MM)",
"verbose_state": "Etat verbeux (texte, au lieu du chiffre)", "verbose_state": "Etat verbeux (texte, au lieu du chiffre)",
"move_country_holidays": "Décaler la collecte si jour férié dans la semaine", "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": { "detail": {
Binary file not shown.