epaper/modules/ical.py

384 lines
18 KiB
Python
Raw Normal View History

2023-01-08 12:46:11 +00:00
from collections.abc import Iterable
from datetime import date, datetime, timedelta, timezone
from functools import reduce
2022-08-19 14:59:00 +00:00
import hashlib
2023-01-16 12:48:41 +00:00
import logging
import re
import time
2022-08-19 14:59:00 +00:00
import urllib.error
2022-08-14 17:51:51 +00:00
import urllib.request
from icalendar import Calendar, Event, vCalAddress, vDDDTypes, vText
2022-08-14 17:51:51 +00:00
from PIL import Image, ImageDraw, ImageFont
import pytz
from . import display_longtext
def next_weekday(d, weekday):
days_ahead = weekday - d.weekday()
if days_ahead < 0: # Target day already happened this week
days_ahead += 7
return d + timedelta(days_ahead)
2022-08-14 17:51:51 +00:00
class IcalModule:
2022-08-14 17:55:12 +00:00
def __init__(self, config):
self.cals = config.cals
2022-08-14 17:51:51 +00:00
2022-08-22 07:28:41 +00:00
self.delayed_departure = 100
self._cached_events = []
2022-08-19 14:59:00 +00:00
self._cached_file = ".ical-%s.cache"
self.cache_time = 15
def __get_events(self, config, toofar=None):
today = datetime.now(tz=pytz.timezone('Europe/Paris')).replace(hour=0, minute=0, second=0)
todayd = date.today()
toofard = None
if toofar is not None:
toofard = date(toofar.year, toofar.month, toofar.day)
2022-08-14 17:51:51 +00:00
events = []
for cal in self.cals:
2022-08-19 14:59:00 +00:00
cache_file = self._cached_file % (hashlib.md5(cal.encode()).hexdigest())
try:
with urllib.request.urlopen(cal) as c:
with open(cache_file, 'wb') as fd:
fd.write(c.read())
except ConnectionResetError as e:
2023-01-16 12:48:41 +00:00
logging.exception(e)
except urllib.error.URLError as e:
2023-01-16 12:48:41 +00:00
logging.exception(e)
2022-08-19 14:59:00 +00:00
with open(cache_file) as c:
2022-08-14 17:51:51 +00:00
ecal = Calendar.from_ical(c.read())
for component in ecal.walk():
2023-12-09 16:12:56 +00:00
if component.name == "VEVENT" and "DTEND" in component:
start = component.decoded("DTSTART")
end = component.decoded("DTEND")
if "RRULE" in component:
rrule = component.decoded("RRULE")
if "UNTIL" in rrule and reduce(lambda p,d: p or d < today, rrule["UNTIL"], False):
continue
if "FREQ" in rrule and "BYDAY" in rrule and "WEEKLY" in rrule["FREQ"]:
weekday = 6
if "MO" in rrule["BYDAY"]:
weekday = 0
elif "TU" in rrule["BYDAY"]:
weekday = 1
elif "WE" in rrule["BYDAY"]:
weekday = 2
elif "TH" in rrule["BYDAY"]:
weekday = 3
elif "FR" in rrule["BYDAY"]:
weekday = 4
elif "SA" in rrule["BYDAY"]:
weekday = 5
2023-01-08 12:42:19 +00:00
diff = end - start
if isinstance(start, datetime):
start = next_weekday(today, weekday).replace(hour=start.hour, minute=start.minute, second=start.second, microsecond=start.microsecond)
else:
start = next_weekday(todayd, weekday)
end = start + diff
2023-01-08 12:46:11 +00:00
exclusions = []
if "EXDATE" in component:
exdates = component.decoded("EXDATE")
if isinstance(exdates, Iterable):
for exdate in exdates:
exclusions.append(exdate.dts[0].dt)
else:
exclusions.append(exdates.dts[0].dt)
while start < component.decoded("DTSTART"):
start += timedelta(days=7)
2023-01-08 12:46:11 +00:00
while start in exclusions:
start += timedelta(days=7)
component["DTSTART"] = vDDDTypes(start)
component["DTEND"] = vDDDTypes(end)
if isinstance(end, datetime):
if end.astimezone(pytz.timezone('Europe/Paris')) < today:
continue
if toofar is not None and start.astimezone(pytz.timezone('Europe/Paris')) > toofar:
continue
else:
if isinstance(end, date) and end < todayd:
continue
if toofard is not None and start > toofard:
continue
2022-08-21 02:20:22 +00:00
events.append(component)
2022-08-14 17:51:51 +00:00
# Sort events
events.sort(key=lambda e: time.mktime(e.decoded("DTSTART").timetuple()))
2022-08-14 17:51:51 +00:00
return events
def _get_events(self, config, toofar=None):
if len(self._cached_events) == 0:
evts = self.__get_events(config, toofar)
self._cached_events = evts
return self._cached_events
def get_events(self, config, toofar=None, now=None):
for component in self._get_events(config, toofar):
evt = None
train_situations, train_conjoncturels, train_start_station, train_end_station, place = self.is_train_event(config, component)
if train_start_station is not None:
start = component.decoded("DTSTART")
end = component.decoded("DTEND")
evt = {
"summary": component.get("SUMMARY"),
"start": start,
"end": end,
}
new_start = datetime.fromisoformat(train_start_station["depart"]["dateHeureReelle"])
if start.astimezone(pytz.timezone('Europe/Paris')).strftime("%d %H:%M") != new_start.strftime("%d %H:%M"):
evt["new_start"] = new_start
if train_end_station:
new_end = datetime.fromisoformat(train_end_station["arrivee"]["dateHeureReelle"])
if end.astimezone(pytz.timezone('Europe/Paris')).strftime("%d %H:%M") != new_end.strftime("%d %H:%M"):
evt["new_end"] = new_end
if len(train_conjoncturels) > 0:
evt["alert"] = train_conjoncturels["messagesConjoncturels"][0]["titre"]
elif len(train_situations) > 0:
evt["alert"] = train_situations[0]["libelleSituation"]
if new_start > datetime.now(tz=pytz.timezone('Europe/Paris')):
if "evenement" in train_start_station["depart"]:
evt["alert"] = train_start_station["depart"]["evenement"]["texte"]
if "voie" in train_start_station and "numero" in train_start_station["voie"]:
evt["info"] = "Départ voie " + train_start_station["voie"]["numero"]
if place is not None:
evt["info"] = ((evt["info"] + "\n") if "info" in evt else "") + place
elif train_end_station is not None:
evt["info"] = "Arrivée à " + new_end.strftime("%H:%M")
if "evenement" in train_end_station["arrivee"]:
evt["alert"] = train_end_station["arrivee"]["evenement"]["texte"]
evt["info"] += " (+%d')" % train_end_station["arrivee"]["evenement"]["retard"]["duree"]
if "voie" in train_end_station and "numero" in train_end_station["voie"]:
evt["info"] += " voie " + train_end_station["voie"]["numero"]
elif now is not None and time.mktime(component.decoded("DTEND").timetuple()) < time.mktime(now.timetuple()):
continue
else:
evt = {
"summary": component.get("SUMMARY"),
"start": component.decoded("DTSTART"),
"end": component.decoded("DTEND"),
}
if "location" in component:
evt["location"] = component.decoded("LOCATION").decode()
if evt is not None:
yield evt
def is_train_event(self, config, evt):
if "description" not in evt:
return None, None, None, None, None
numero_train = None
ville_depart = None
ville_arrivee = None
place = None
for line in evt.decoded("description").decode().split("\n"):
res = re.match(r'.*Train.*([0-9]{4,})$', line)
if res is not None:
numero_train = res[1]
res = re.match(r'.*[0-9]{1,2}h[0-9]{2} (.*)$', line)
if res is not None:
if ville_depart is not None:
ville_arrivee = res[1]
else:
ville_depart = res[1]
res = re.match(r'.*Voiture ([0-9]+).*place([0-9]+)', line)
if res is not None:
place = "voit. " + res[1] + " pl. " + res[2]
if numero_train is None:
return None, None, None, None, place
start_time = evt.decoded("DTSTART")
now = datetime.now(tz=pytz.timezone('Europe/Paris'))
from .sncf import SNCFAPI
status = SNCFAPI(config).get_train_status(numero_train, start_time)
if status is None:
return None, None, None, None, place
situations = []
if "situation" in status and len(status["situation"]) > 0:
situations = status["situation"]
conjoncturels = []
if "listeMessagesConjoncturels" in status and len(status["listeMessagesConjoncturels"]) > 0:
conjoncturels = status["listeMessagesConjoncturels"]
if ville_arrivee is None:
return situations, conjoncturels, None, None, place
start_station = None
end_station = None
for arret in status["listeArretsDesserte"]["arret"]:
if arret["emplacement"]["libelle"].startswith(ville_arrivee):
end_station = arret
if arret["emplacement"]["libelle"].startswith(ville_depart):
start_station = arret
return situations, conjoncturels, start_station, end_station, place
2022-08-21 02:03:33 +00:00
def event_coming(self, config):
now = time.mktime(datetime.now(tz=pytz.timezone('Europe/Paris')).timetuple())
coming = time.mktime((datetime.now(tz=pytz.timezone('Europe/Paris')) + timedelta(minutes=self.delayed_departure)).timetuple())
2022-08-21 02:03:33 +00:00
for evt in self.get_events(config, toofar=datetime.fromtimestamp(coming, tz=pytz.timezone('Europe/Paris'))):
2022-08-21 02:03:33 +00:00
# Looking only the first event
start = time.mktime(evt["start"].timetuple())
2022-08-21 02:03:33 +00:00
return now < start and start < coming
return False
def non_local_event_coming(self, config):
now = time.mktime(datetime.now(tz=pytz.timezone('Europe/Paris')).timetuple())
coming = time.mktime((datetime.now(tz=pytz.timezone('Europe/Paris')) + timedelta(minutes=self.delayed_departure)).timetuple())
for evt in self.get_events(config, toofar=datetime.fromtimestamp(coming, tz=pytz.timezone('Europe/Paris'))):
if "location" in evt and (
evt["location"].lower().startswith("http") or
evt["location"].lower().startswith("ici") or
evt["location"].lower().endswith("gentilly") or
evt["location"].lower().endswith("gentilly, france")
):
continue
# Looking only the first event
start = time.mktime(evt["start"].timetuple())
return now < start and start < coming
return False
def local_event_ending(self, config):
now = time.mktime((datetime.now(tz=pytz.timezone('Europe/Paris')) + timedelta(minutes=self.delayed_departure/3)).timetuple())
coming = time.mktime((datetime.now(tz=pytz.timezone('Europe/Paris')) + timedelta(minutes=self.delayed_departure/3)).timetuple())
for evt in self.get_events(config, toofar=datetime.fromtimestamp(coming, tz=pytz.timezone('Europe/Paris'))):
if not("location" in evt and evt["location"].lower().startswith("ici")):
continue
# Looking only the first event
end = time.mktime(evt["end"].timetuple())
return now < end and end < coming
return False
def draw_module(self, config, width, height, line_height=19):
now = datetime.now(tz=pytz.timezone('Europe/Paris'))
events = self.get_events(config, now + timedelta(weeks=1), now)
2022-08-14 17:51:51 +00:00
image = Image.new('RGB', (width, height), '#fff')
draw = ImageDraw.Draw(image)
fnt_R = ImageFont.truetype(config.fnt_R_path, int(line_height*0.7))
fnt_B = ImageFont.truetype(config.fnt_RB_path, int(line_height*0.7))
last_evt = None
align = 0
for evt in events:
last_day = None
if last_evt is not None:
last_day = last_evt["start"].astimezone(pytz.timezone('Europe/Paris')).day if isinstance(last_evt["start"], datetime) else last_evt["start"].timetuple()[2]
cur_day = evt["start"].astimezone(pytz.timezone('Europe/Paris')).day if isinstance(evt["start"], datetime) else evt["start"].timetuple()[2]
if last_day is None or last_day != cur_day:
2022-08-14 17:51:51 +00:00
draw.text(
(width / 2, align),
evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%a %d %B") if isinstance(evt["start"], datetime) else evt["start"].strftime("%a %d %B"),
2022-08-14 17:51:51 +00:00
fill="black", anchor="mt", font=fnt_B
)
align += line_height
draw.rectangle((0, align-4, width, align-4), fill="black")
if isinstance(evt["start"], datetime):
draw.text(
(2, align),
evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M") + " " + evt["summary"],
fill="black", anchor="lt", font=fnt_R
)
else:
draw.text(
(width/2, align),
evt["summary"],
fill="black", anchor="mt", font=fnt_B
)
if "new_start" in evt:
draw.line(
2023-12-09 16:40:39 +00:00
(2, align + line_height / 2.6, 2 + fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M"))[0], align + line_height / 2.6),
fill="black"
)
draw.text(
(0, align+line_height*0.6),
evt["new_start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M"),
fill="black", anchor="lt", font=fnt_B
)
if "alert" in evt:
draw.text(
2023-12-09 16:40:39 +00:00
(2 + fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0], align+line_height*0.6),
evt["alert"],
fill="black", anchor="lt", font=fnt_R
)
align += line_height * 0.7
2022-08-21 02:20:22 +00:00
if "location" in evt and now.day == evt["start"].day:
2022-09-20 07:57:39 +00:00
if isinstance(evt["start"], datetime):
align += display_longtext(draw,
2023-12-09 16:40:39 +00:00
(2 + fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0], align+line_height*0.6),
2022-09-20 07:57:39 +00:00
evt["location"],
fill="black", anchor="lt", font=fnt_R
)
2022-09-20 08:09:48 +00:00
elif evt["location"].lower() != "ici":
2022-09-20 07:57:39 +00:00
align += display_longtext(draw,
(width/2, align+line_height*0.6),
evt["location"],
fill="black", anchor="mt", font=fnt_R
)
if "info" in evt:
2022-09-20 07:57:39 +00:00
if isinstance(evt["start"], datetime):
align += display_longtext(draw,
2023-12-09 16:40:39 +00:00
(2 + fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0], align+line_height*0.6),
2022-09-20 07:57:39 +00:00
evt["info"],
2023-12-09 16:40:39 +00:00
fill="black", anchor="lt", font=fnt_R, maxwidth=width - fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0]
2022-09-20 07:57:39 +00:00
)
else:
align += display_longtext(draw,
(width/2, align+line_height*0.6),
evt["info"],
fill="black", anchor="mt", font=fnt_R
)
elif "end" in evt and not isinstance(evt["end"], datetime):
pass
elif "end" in evt and (("new_start" in evt and now > evt["new_start"].astimezone(pytz.timezone('Europe/Paris'))) or ("new_start" not in evt and now > evt["start"].astimezone(pytz.timezone('Europe/Paris')))):
align += display_longtext(draw,
2023-12-09 16:40:39 +00:00
(2 + fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0], align+line_height*0.6),
"Fin à " + (evt["new_end"] if "new_end" in evt else evt["end"]).astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M"),
2023-12-09 16:40:39 +00:00
fill="black", anchor="lt", font=fnt_R, maxwidth=width - fnt_R.getbbox(evt["start"].astimezone(pytz.timezone('Europe/Paris')).strftime("%H:%M "))[0]
)
2022-08-14 17:51:51 +00:00
align += line_height
last_evt = evt
return image