Files
ad_trident/apps/alarmclock.py
T
Pierre 9802982d73 fix(alarmclock): fiabiliser le rafraîchissement du calendrier ICS
- Ajouter deux tentatives de reprise après 2 puis 5 minutes en cas d’échec.
- Mémoriser le dernier succès et adapter le niveau des logs à l’ancienneté du calendrier.
- Distinguer les erreurs réseau, HTTP et les données ICS invalides.
- Définir les délais de connexion et de lecture et vérifier le statut HTTP.
- Actualiser le statut du jour travaillé et l’heure du réveil après une reprise.
2026-11-10 19:31:13 +01:00

415 lines
20 KiB
Python

import appdaemon.plugins.hass.hassapi as hass
from ad_toolbox.smartobject import SmartObject
import ad_toolbox.smartcondition as SmartCondition
import datetime
from threading import Lock
from requests import get
import requests
import icalendar
from enum import Enum
class EventType(Enum):
Vacation = 1
Flex = 2
Busy = 3
Tentative = 4
class CalendarEvent:
def __init__(self,start_time,stop_time,event_type):
self.start_time = start_time
self.stop_time = stop_time
self.type = event_type
def __str__(self):
return f"[{self.type}]Start at {self.start_time},Finish at {self.stop_time}"
def __eq__(self, obj):
return isinstance(obj, CalendarEvent) and obj.type == self.type and obj.start_time == self.start_time and obj.stop_time == self.stop_time
def __ne__(self, obj):
return not self == obj
class AlarmClock(SmartObject):
CALENDAR_RETRY_DELAYS = (2 * 60, 5 * 60)
@property
def calendar_last_success(self):
return self.dataset.get('calendar_last_success')
@calendar_last_success.setter
def calendar_last_success(self, value):
self.dataset['calendar_last_success'] = value
def on_initialize_smart_object(self):
if self.dataset is None:
self.dataset = {}
self.switch_alarmclock_main = self.args['switch_alarmclock_main']
self.switch_alarmclock_secondary = self.args['switch_alarmclock_secondary']
self.inputdate_alarmclock_main = self.args['inputdate_alarmclock_main']
self.inputdate_alarmclock_secondary = self.args['inputdate_alarmclock_secondary']
self.output_workday_sensor = self.args['output_workday_sensor']
self.sensor_wakeup_datetime_sensor = self.args['sensor_wakeup_datetime']
self.sleeping_condition = SmartCondition.Evaluator(self, self.args['sleeping_condition'], condition_name="sleeping_condition", constants=self.constants, templates_library=self.templates_library)
self.lock = Lock()
self.wakeup_time_str = ""
self.handle_program_next_wakueup_cb = None
self.handle_prewakueup_cb = None
self.handle_wakueup_cb = None
self.calendar_retry_handle = None
self.calendar_retry_attempt = 0
self.handle_switch_reveil_principal = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_main)
self.handle_switch_reveil_secondaire = self.listen_state(self.on_alarm_clock_set, self.switch_alarmclock_secondary)
self.listen_state(self.on_alarm_clock_set, self.inputdate_alarmclock_main)
self.listen_state(self.on_alarm_clock_set, self.inputdate_alarmclock_secondary)
self.run_hourly(self.on_refresh_cal,datetime.time(0, 0, 0))
self.sensor_wakeup_datetime_entity = self.create_entity(self.sensor_wakeup_datetime_sensor, icon="mdi:alarm")
self.workday_entity = self.create_entity(self.output_workday_sensor, icon="mdi:briefcase-check")
self.calendar = list()
self.refresh_cal()
self.update_workday_status()
self.update_wakeup_time()
def refresh_cal(self):
with self.log_group("refresh_cal"):
new_cal = list()
#if self.load_cal_from_google_calendar(new_cal):
if self.load_cal_from_ics(new_cal):
self.calendar_last_success = datetime.datetime.now(datetime.timezone.utc)
self.calendar_retry_attempt = 0
self._cancel_calendar_retry()
if not self.are_cal_identical(self.calendar,new_cal):
self.calendar = new_cal
self.log_info("Calendar has been updated!")
self.print_cal(self.calendar)
return True
else:
self._schedule_calendar_retry()
return False
def _log_calendar_failure(self, reason):
last_success = self.calendar_last_success
if last_success is None:
log = self.log_error
freshness = 'no successful refresh recorded'
else:
age = datetime.datetime.now(datetime.timezone.utc) - last_success
if age < datetime.timedelta(hours=24):
log = self.log_info
elif age < datetime.timedelta(hours=48):
log = self.log_warning
else:
log = self.log_error
freshness = (f'last_success={last_success.isoformat()}, '
f'age={age.total_seconds() / 3600:.2f}h')
log(f"Couldn't load calendar: {reason}; {freshness}")
def _schedule_calendar_retry(self):
if self.calendar_retry_handle is not None:
return
if self.calendar_retry_attempt >= len(self.CALENDAR_RETRY_DELAYS):
return
delay = self.CALENDAR_RETRY_DELAYS[self.calendar_retry_attempt]
self.calendar_retry_attempt += 1
self.calendar_retry_handle = self.run_in(self.on_calendar_retry, delay)
self.log_info(f"Calendar refresh retry scheduled in {delay // 60} minutes")
def _cancel_calendar_retry(self):
if self.calendar_retry_handle is None:
return
if self.timer_running(self.calendar_retry_handle):
self.cancel_timer(self.calendar_retry_handle)
self.calendar_retry_handle = None
def on_calendar_retry(self, kwargs):
self.calendar_retry_handle = None
calendar_changed = self.refresh_cal()
self.update_workday_status()
if calendar_changed:
self.update_wakeup_time()
def are_cal_identical(self,calendar1,calendar2):
if len(calendar1) != len(calendar2): return False
for i, event in enumerate(calendar1):
if calendar2[i] != event:
return False
return True
def print_cal(self,calendar):
for event in calendar:
self.log_info(f"{event}")
def load_cal_from_ics(self,calendar):
calendar.clear()
try:
self.log_info(f"Retrieving calengar from outlook.office365.com")
r = get("https://outlook.office365.com/owa/calendar/0c64359c70eb41cd9713fe92c470d3df@ubisoft.com/5edb3fb5150f4203b66d9cf8db3184be11626033572842691749/calendar.ics", timeout=(3, 5))#, headers=headers, verify=False)
r.raise_for_status()
#self.log_info(f"Processing Calendar")
today = datetime.datetime.combine(datetime.date.today(), datetime.time(0,0, 0))
start_load_date = today - datetime.timedelta(days=1)
end_load_date = today + datetime.timedelta(days=2)
#self.log_info("[" + str(start_load_date) + "]-[" + str(end_load_date) + "]")
calendar_ubi = icalendar.Calendar.from_ical(r.content)
for component in calendar_ubi.walk():
if component.name == "VEVENT":
event_summary = component.get('summary')
if event_summary == "Away" or event_summary == "Absent(e)": event_type = EventType.Vacation
elif event_summary == "Busy" or event_summary == "Occupé(e)": event_type = EventType.Busy
elif event_summary == "Tentative" or event_summary == "Provisoire": event_type = EventType.Tentative
elif event_summary == "Working elsewhere" or event_summary == "Free" or event_summary == "Disponible": event_type = EventType.Flex
else: event_type = None
start_datetime = component.get('dtstart').dt
end_datetime = component.get('dtend').dt
#regular event have a datetime and full day events only a date
#let's make both event a time zone unaware datetime
if isinstance(start_datetime,datetime.datetime):
start_datetime = start_datetime.replace(tzinfo=None)
else:
start_datetime = datetime.datetime.combine(start_datetime,datetime.time(0,0,0))
if isinstance(end_datetime,datetime.datetime):
end_datetime = end_datetime.replace(tzinfo=None)
else:
end_datetime = datetime.datetime.combine(end_datetime,datetime.time(0,0,0))
outside_of_loading_period = False
if start_datetime < start_load_date and end_datetime < start_load_date:
outside_of_loading_period = True
if start_datetime > end_load_date and end_datetime > end_load_date:
outside_of_loading_period = True
if not outside_of_loading_period:
if event_type:
calendar.append(CalendarEvent(start_datetime,end_datetime,event_type))
else: self.log_warning(f"[Unknow event : {event_summary}]Start at {start_datetime},Finish at {end_datetime}")
except requests.exceptions.Timeout as error:
self._log_calendar_failure(f"request timed out ({type(error).__name__})")
return False
except requests.exceptions.ConnectionError:
self._log_calendar_failure("connection failed")
return False
except requests.exceptions.HTTPError as error:
self._log_calendar_failure(f"HTTP {error.response.status_code}")
return False
except requests.exceptions.RequestException as error:
self._log_calendar_failure(type(error).__name__)
return False
except (ValueError, TypeError, KeyError):
self._log_calendar_failure("invalid ICS data")
return False
#self.log_info(f"Calendar loaded")
return True
def on_alarm_clock_set(self, entity, attribute, old, new, kwargs):
self.update_wakeup_time()
def on_refresh_cal(self, kwargs):
self._cancel_calendar_retry()
self.calendar_retry_attempt = 0
calendar_changed = self.refresh_cal()
self.update_workday_status()
if calendar_changed:
self.update_wakeup_time()
def update_workday_status(self):
with self.log_group("update_workday_status"):
is_workday = self.is_wakeup_time_on_a_working_day(datetime.datetime.now())
new_state = "on" if is_workday else "off"
if self.workday_entity.get_state() != new_state:
self.workday_entity.set_state(state=new_state)
def on_program_next_wakeup(self, kwargs):
self.handle_program_next_wakueup_cb = None
no_switch_changed = True
#le reveil principal se reactive chaque matin
if self.get_state(self.switch_alarmclock_main) == 'off':
self.log_info("Turning on main alarm clock")
no_switch_changed = False
self.turn_on(self.switch_alarmclock_main)
#le reveil secondaire ne sonne qu'une fois, il est donc desactivé chaque matin
if self.get_state(self.switch_alarmclock_secondary) == 'on':
self.log_info("Turning off secondary alarm clock")
no_switch_changed = False
self.turn_off(self.switch_alarmclock_secondary)
if no_switch_changed:
self.update_wakeup_time()
def on_pre_wakeup(self, kwargs):
self.handle_prewakueup_cb = None
for key, value in kwargs.items():
if key == 'wakeup_in' :
wakeup_in = value
if wakeup_in > 0:
self.handle_prewakueup_cb = self.run_in(self.on_pre_wakeup, 1 * 60,wakeup_in = wakeup_in - 1)
wakeup_message = "On se reveil dans " + str(wakeup_in) + " minutes"
self.log_info(wakeup_message)
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP_IN", wakeup_in = wakeup_in)
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
def on_wakeup(self, kwargs):
self.handle_wakueup_cb = None
wakeup_message = "WAKE UP!!!! It's " + self.wakeup_time_str
self.log_info(wakeup_message)
if self.sleeping_condition.evaluate() == SmartCondition.Result.Succeeded: self.fire_event("WAKEUP")
#self.call_service("notify/ios_iphone_de_pierre", title = "Reveil", message = wakeup_message)
def is_wakeup_time_on_a_working_day(self, wakeup_datetime):
if self.is_during_vacation(wakeup_datetime):
return False
if wakeup_datetime.weekday() >= 5: #saturday is 5, sunday is 6
return False
return True
def is_during_vacation(self, date_time):
for event in self.calendar:
if event.type == EventType.Vacation and event.start_time <= date_time and date_time <= event.stop_time:
return True
return False
def is_wakeup_time_on_a_flex_day(self, date_time):
return False
#we'll see about that later
for event in self.calendar:
if event.type == EventType.Flex and event.start_time <= date_time and date_time <= event.stop_time:
return True
return False
def get_next_busy(self,date_time):
for event in self.calendar:
if event.type == EventType.Busy and event.start_time >= date_time:
return event
return None
def offset_wakeup_time_for_meeting(self,wakeup_datetime):
date_time = wakeup_datetime - datetime.timedelta(hours=wakeup_datetime.hour,minutes=wakeup_datetime.minute)
busy_event = self.get_next_busy(date_time)
if busy_event and busy_event.start_time.date() == date_time.date() and busy_event.start_time.hour > 6 and busy_event.start_time <= wakeup_datetime:
wake_up_datetime = busy_event.start_time - datetime.timedelta(minutes=30)
new_wake_up_time = wake_up_datetime.strftime("%H:%M")
self.log_info(f"A meeting have been found at {busy_event.start_time}, we'll wake up at {new_wake_up_time}")
return new_wake_up_time
new_wake_up_time = wakeup_datetime.strftime("%H:%M")
#self.log_info(f"No meeting have been found before {wake_up_time}, we'll wake up at {wake_up_time}")
return new_wake_up_time
def calculate_wakeup_time(self):
new_wakeup_time = "--:--"
if self.get_state(self.switch_alarmclock_secondary) == 'on':
new_wakeup_time = self.get_state(self.inputdate_alarmclock_secondary)[0:5]
elif self.get_state(self.switch_alarmclock_main) == 'on':
max_flex_wakeup_time = "10:00" #todo: make that data driven
max_flex_wakeup_datetime = self.generate_wakeup_datetime_from_string(max_flex_wakeup_time)
new_wakeup_time = self.get_state(self.inputdate_alarmclock_main)[0:5]
new_wakeup_datetime = self.generate_wakeup_datetime_from_string(new_wakeup_time)
if not self.is_wakeup_time_on_a_working_day(new_wakeup_datetime):
self.log_info(f"Primary Wake up time ({new_wakeup_datetime}) is not on a working day")
new_wakeup_time = "--:--"
elif self.is_wakeup_time_on_a_flex_day(max_flex_wakeup_datetime):
self.log_info(f"Flex Wake up time ({max_flex_wakeup_datetime}) is on a flex office day")
new_wakeup_time = self.offset_wakeup_time_for_meeting(max_flex_wakeup_datetime)
else:
new_wakeup_time = self.offset_wakeup_time_for_meeting(new_wakeup_datetime)
return new_wakeup_time
def generate_wakeup_datetime_from_string(self,wakeup_string):
wakeup_time = datetime.time(int(wakeup_string[0:2]), int(wakeup_string[3:5]), 0)
wakeup_datetime = datetime.datetime.combine(datetime.date.today(),wakeup_time)
if (datetime.datetime.combine(datetime.date.today(), wakeup_time) - datetime.datetime.now()).total_seconds() <= 0:
wakeup_datetime = wakeup_datetime + datetime.timedelta(days=1)
return wakeup_datetime
def register_callbacks(self, wakeup_time_str):
if self.wakeup_time_str != wakeup_time_str or not self.handle_program_next_wakueup_cb:
with self.lock:
#first, cancel all previous callbacks
if self.handle_program_next_wakueup_cb:
self.log_info("Canceling previous program next wakeup cb")
self.cancel_timer(self.handle_program_next_wakueup_cb)
self.handle_program_next_wakueup_cb = None
if self.handle_prewakueup_cb:
self.log_info("Canceling previous pre wakeup cb")
self.cancel_timer(self.handle_prewakueup_cb)
self.handle_prewakueup_cb = None
if self.handle_wakueup_cb:
self.log_info("Canceling previous wakeup cb")
self.cancel_timer(self.handle_wakueup_cb)
self.handle_wakueup_cb = None
if wakeup_time_str == '--:--':
#if their is no alarm clock set up, we just need to program a callback to update the wakeup time tomorow
# 12:00 should do it
program_next_wakeup_time = datetime.datetime.combine(datetime.date.today(), datetime.time(12,0, 0))
#if we toggle off an alalrm clock before 4, we assume it's for the 'next' day (meaning tbe same day between 0 and 4)
#if we toggle off an alalrm clock after 4, it's suppose to stay so untill next day
if datetime.datetime.now().hour >= 4:
program_next_wakeup_time = program_next_wakeup_time + datetime.timedelta(days=1)
self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_time))
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_time)
self.sensor_wakeup_datetime_entity.set_state(state = 'unavailable')
else:
wakeup_datetime = self.generate_wakeup_datetime_from_string(wakeup_time_str)
self.sensor_wakeup_datetime_entity.set_state(state = wakeup_datetime.strftime("%H:%M"))
#the program next wakeup CB need to be slightly after the wakeup CB to avoid the risk of wakeup CB cleaning it's handle AFTER program next wakup set up the next one
program_next_wakeup_datetime = wakeup_datetime + datetime.timedelta(seconds=1)
prewakeup_offset = min(60,(wakeup_datetime - datetime.datetime.now()).total_seconds() / 60)
prewakeup_offset = int(prewakeup_offset / 10) * 10
prewakeup_datetime = wakeup_datetime - datetime.timedelta(minutes=prewakeup_offset)
self.handle_prewakueup_cb = self.run_at(self.on_pre_wakeup, prewakeup_datetime,wakeup_in = prewakeup_offset)
self.log_info("prewakup cb registered for " + str(prewakeup_datetime))
self.handle_wakueup_cb = self.run_at(self.on_wakeup, wakeup_datetime)
self.log_info("wakeup cb registered for " + str(wakeup_datetime))
self.handle_program_next_wakueup_cb = self.run_at(self.on_program_next_wakeup, program_next_wakeup_datetime)
self.log_info("program next wakeup cb registered for " + str(program_next_wakeup_datetime))
self.wakeup_time_str = wakeup_time_str
#self.call_service("input_text/set_value", entity_id = self.input_text_wakeup_time, value = self.wakeup_time_str)
def update_wakeup_time(self):
with self.log_group("update_wakeup_time"):
self.register_callbacks(self.calculate_wakeup_time())