initial commit

This commit is contained in:
2022-12-16 23:40:58 +01:00
parent 7b9732b544
commit 3cb214fa58
7 changed files with 649 additions and 0 deletions

75
CalendarSync.py Normal file
View File

@@ -0,0 +1,75 @@
import caldav
import datetime
import logging
import re
import vobject
class CalendarSync(object):
log = logging.getLogger("CalendarSync")
startTime = "06:00"
def __init__(self):
self._calendar = None
self._client = None
def connect(self, url, user, passwd):
self._client = caldav.DAVClient(url=url, username=user, password=passwd)
@property
def is_connected(self) -> bool:
return self._client is not None
def getExistingMuellEvents(self) -> list[caldav.Event]:
events = self._calendar.events()
ret = []
for e in events:
try:
uid = e.vobject_instance.vevent.uid.value
m = re.match(r'(.+)@MyMuell', uid)
if m is not None:
ret.append(e)
except AttributeError:
continue
return ret
def createEvent(self, uid, summary, date):
date_start = datetime.datetime.combine(date, datetime.time.fromisoformat(CalendarSync.startTime))
date_end = date_start + datetime.timedelta(minutes=5)
cal = vobject.iCalendar()
ev = cal.add('vevent')
ev.add("summary").value = summary
ev.add("dtstart").value = date_start
ev.add("dtend").value = date_end
ev.add("uid").value = str(uid) + "@MyMuell"
ev.add("valarm").add("trigger").value = datetime.timedelta(hours=-12)
ev.add("valarm").add("trigger").value = datetime.timedelta(hours=0)
self._calendar.save_event(cal.serialize())
def syncEvents(self, e):
self.createEvent(
e["id"],
e["title"],
datetime.datetime.strptime(e["day"], "%Y-%m-%d"))
def getCalendars(self):
principal = self._client.principal()
cals = principal.calendars()
ret = []
for c in cals:
ret.append(c.name)
return ret
def createCalendar(self, cal):
principal = self._client.principal()
cals = principal.calendars()
for c in cals:
if c.name == cal:
self._calendar = c
if self._calendar is None:
CalendarSync.log.info("creating new calendar \"{}\"".format(cal))
self._calendar = principal.make_calendar(name=cal)

35
GuiWorker.py Normal file
View File

@@ -0,0 +1,35 @@
from PyQt5.QtCore import QThread, pyqtSignal
from PyQt5.QtWidgets import QProgressBar, QPushButton, QStatusBar
class GuiWorker(QThread):
finished = pyqtSignal(bool, str)
rangeChanged = pyqtSignal(int, int)
progressChanged = pyqtSignal(int)
stateChanged = pyqtSignal(str)
_button = None
def __init__(self, runnable, parent=None):
QThread.__init__(self, parent)
self._callable = runnable
def connectProgressBar(self, progress_bar: QProgressBar):
self.progressChanged.connect(progress_bar.setValue)
self.rangeChanged.connect(progress_bar.setRange)
progress_bar.setTextVisible(True)
def connectButton(self, button: QPushButton):
self._button = button
self._button.clicked.connect(self.start)
def connectStatusBar(self, statusbar: QStatusBar):
self.stateChanged.connect(lambda val: statusbar.showMessage(val))
def run(self):
if self._button:
self._button.setEnabled(False)
ret = self._callable(self)
self._button.setEnabled(True)
self.finished.emit(ret[0], ret[1])

75
LocalDataStorage.py Normal file
View File

@@ -0,0 +1,75 @@
from appdirs import *
import json
import os
import re
import copy
from cryptography.fernet import Fernet
class LocalDataStorage(object):
def __init__(self):
self.appname = "MyMuellDav"
self.appauthor = "Av3m"
self.__fernet = Fernet(b'kWUFurHmtMWX6nOMhpFR45DpuNVPckSQ9t95_ADG2dA=')
if not os.path.exists(self.user_data_dir):
os.makedirs(self.user_data_dir)
DefaultSettings = {
'url': '',
'user': '',
'password': '',
'calendar': '',
'mymuellcity': '',
}
@property
def user_data_dir(self):
return user_data_dir(self.appname, self.appauthor)
@property
def file_settings(self):
return os.path.join(self.user_data_dir, "settings.json")
@property
def file_city_data(self):
return os.path.join(self.user_data_dir, "city_data.json")
@property
def settings(self):
if os.path.exists(self.file_settings):
with open(self.file_settings, "r") as f:
j = json.load(f)
j["password"] = str(self.__fernet.decrypt(bytes(j["password"], encoding="utf-8")), encoding="utf-8")
return j
else:
return LocalDataStorage.DefaultSettings
@settings.setter
def settings(self, val):
if val is None and os.path.exists(self.file_settings):
os.remove(self.file_settings)
return
with open(self.file_settings, "w+") as f:
v = copy.copy(val)
v["password"] = str(self.__fernet.encrypt(bytes(v["password"], encoding="utf-8")), encoding="utf-8")
json.dump(v, f)
os.chmod(self.file_settings, 0o0600)
@property
def city_data(self):
if os.path.exists(self.file_city_data):
with open(self.file_city_data, "r") as f:
return json.load(f)
else:
return None
@city_data.setter
def city_data(self, val):
if val is None and os.path.exists(self.file_city_data):
os.remove(self.file_city_data)
return
with open(self.file_city_data, "w+") as f:
json.dump(val, f)

316
MyMuell2CalDavGui.py Normal file
View File

@@ -0,0 +1,316 @@
from PyQt5.QtWidgets import \
QApplication, \
QWidget, \
QListWidget, \
QListWidgetItem, \
QVBoxLayout, \
QHBoxLayout, \
QGridLayout, \
QLineEdit, \
QGroupBox, \
QLabel, \
QPushButton, \
QMessageBox, \
QComboBox, \
QProgressBar, \
QStatusBar, \
QSizePolicy, \
QMainWindow
from PyQt5.QtCore import QModelIndex, Qt
import MyMuellDataModel
import sys
import CalendarSync
from GuiWorker import GuiWorker
class MyMuell2CalDavGui(QMainWindow):
def __init__(self, parent=None):
super().__init__(parent)
self._dataModel = MyMuellDataModel.MyMuellDataModel()
self._davClient = CalendarSync.CalendarSync()
self._selectedCity = None
self._cities = []
self._citiesWidget = QListWidget()
self._filterText = QLineEdit()
self._url = QLineEdit()
self._user = QLineEdit()
self._password = QLineEdit()
self._calendarNames = QComboBox()
self._connectButton = QPushButton("connect")
self._syncButton = QPushButton("sync events")
self._deleteButton = QPushButton("delete existing events")
self._errorMessage = QMessageBox()
self._progressBar = QProgressBar()
self._statusBar = QStatusBar()
self._settings = self._dataModel.storage.settings
self._url.setText(self._settings["url"])
self._password.setText(self._settings["password"])
self._user.setText(self._settings["user"])
self._workerConnect = GuiWorker(self.runnable_connect_caldav)
self._workerSync = GuiWorker(self.runnable_sync_events)
self._workerDelete = GuiWorker(self.runnable_delete_events)
self.initUI()
self.__fillCities()
def entrySelected(self, i: QListWidgetItem):
city = self._dataModel.get_city_by_id(i.data(QListWidgetItem.UserType))
self._selectedCity = (city["id"], city["area_id"])
def saveSettings(self, val):
self._settings["url"] = self._url.text()
self._settings["user"] = self._user.text()
self._settings["password"] = self._password.text()
if self._calendarNames.currentText() != '':
self._settings["calendar"] = self._calendarNames.currentText()
if len(self._citiesWidget.selectedItems()) > 0:
self._settings["mymuellcity"] = self._citiesWidget.selectedItems()[0].text()
self._dataModel.storage.settings = self._settings
def runnable_sync_events(self, worker: GuiWorker) -> tuple[bool, str]:
if self._selectedCity is None:
return False, "please select a city"
if self._calendarNames.currentText() == '':
return False, "please select a calendar"
worker.stateChanged.emit("create calendar {} if not existent".format(self._calendarNames.currentText()))
self._davClient.createCalendar(self._calendarNames.currentText())
worker.stateChanged.emit("get events from MyMüll.de (city id {}, aread id {}".format(*self._selectedCity))
events = self._dataModel.get_events(*self._selectedCity)
worker.rangeChanged.emit(0, len(events))
worker.progressChanged.emit(0)
for i in range(0, len(events)):
worker.stateChanged.emit("creating event {} {}".format(events[i]["title"], events[i]["day"]))
self._davClient.syncEvents(events[i])
worker.progressChanged.emit(i+1)
worker.stateChanged.emit("syncing events finished")
return True, "syncing events finished"
def runnable_delete_events(self, worker: GuiWorker) -> tuple[bool, str]:
if self._calendarNames.currentText() == '':
return False, "please select a calendar"
worker.stateChanged.emit("deleting existing events from calendar")
self._davClient.createCalendar(self._calendarNames.currentText())
events = self._davClient.getExistingMuellEvents()
if len(events) > 0:
worker.rangeChanged.emit(0, len(events))
worker.progressChanged.emit(0)
else:
worker.rangeChanged.emit(0, 1)
worker.progressChanged.emit(1)
for i in range(0, len(events)):
worker.stateChanged.emit("deleting event {}".format(events[i].vobject_instance.vevent.uid.value))
events[i].delete()
worker.progressChanged.emit(i+1)
worker.stateChanged.emit("deletion finished.")
return True, "deleting events finished"
def runnable_connect_caldav(self, worker: GuiWorker) -> tuple[bool, str]:
try:
worker.stateChanged.emit("connecting to {}".format(self._url.text()))
self._davClient.connect(self._url.text(), self._user.text(), self._password.text())
self._calendarNames.blockSignals(True)
for i in self._davClient.getCalendars():
self._calendarNames.addItem(i)
self._calendarNames.blockSignals(False)
self._calendarNames.setEnabled(True)
if self._settings["calendar"] != '':
self._calendarNames.setCurrentText(self._settings["calendar"])
worker.stateChanged.emit("connected.")
except Exception as e:
worker.stateChanged.emit("connection failed.")
return False, str(e)
return True, "connect successful"
def initUI(self):
tlWidget = QWidget()
layout = QGridLayout()
tlWidget.setLayout(layout)
self.setCentralWidget(tlWidget)
groupBoxMyMuell = QGroupBox("MyMüll.de Cities")
layoutGroupBoxMyMuell = QGridLayout()
groupBoxMyMuell.setLayout(layoutGroupBoxMyMuell)
groupBoxCalDav = QGroupBox("CalDAV Settings")
layoutGroupBoxCalDav = QGridLayout()
groupBoxCalDav.setLayout(layoutGroupBoxCalDav)
groupBoxProgress = QGroupBox("Progress")
layoutGroupBoxProgress = QVBoxLayout()
groupBoxProgress.setLayout(layoutGroupBoxProgress)
layoutGroupBoxMyMuell.addWidget(self._citiesWidget, 0, 0, 4, 6)
layoutGroupBoxMyMuell.addWidget(QLabel("Filter Cities"), 4, 0, 1, 1)
layoutGroupBoxMyMuell.addWidget(self._filterText, 4, 1, 1, 5)
layout.addWidget(groupBoxMyMuell)
layout.addWidget(groupBoxCalDav)
layout.addWidget(groupBoxProgress)
self.setStatusBar(self._statusBar)
layoutGroupBoxCalDav.addWidget(QLabel("url"), 0, 0, 1, 1)
layoutGroupBoxCalDav.addWidget(self._url, 0, 1, 1, 5)
layoutGroupBoxCalDav.addWidget(QLabel("username"), 1, 0, 1, 1)
layoutGroupBoxCalDav.addWidget(self._user, 1, 1, 1, 5)
layoutGroupBoxCalDav.addWidget(QLabel("password"), 2, 0, 1, 1)
layoutGroupBoxCalDav.addWidget(self._password, 2, 1, 1, 5)
layoutGroupBoxCalDav.addWidget(QLabel("calendar"), 3, 0, 1, 1)
layoutGroupBoxCalDav.addWidget(self._calendarNames, 3, 1, 1, 5)
buttonLayout = QHBoxLayout()
buttonLayout.addWidget(self._connectButton)
buttonLayout.addWidget(self._syncButton)
buttonLayout.addWidget(self._deleteButton)
layoutGroupBoxCalDav.addLayout(buttonLayout, 4, 0, 6, 6)
layoutGroupBoxProgress.addWidget(self._progressBar)
layoutGroupBoxCalDav.setSpacing(0)
layoutGroupBoxCalDav.setContentsMargins(0, 0, 0, 0)
self._password.setEchoMode(QLineEdit.Password)
self._calendarNames.setEditable(True)
self._filterText.show()
self._citiesWidget.show()
self._syncButton.setEnabled(False)
self._calendarNames.setEnabled(False)
self._citiesWidget.currentItemChanged.connect(lambda cur, prev: self.entrySelected(cur))
self._filterText.textChanged.connect(self.__fillCities)
self._url.textChanged.connect(self.saveSettings)
self._user.textChanged.connect(self.saveSettings)
self._password.textChanged.connect(self.saveSettings)
self._citiesWidget.itemSelectionChanged.connect(lambda: self.saveSettings(0))
self._workerSync.connectProgressBar(self._progressBar)
self._workerSync.connectButton(self._syncButton)
self._workerConnect.connectButton(self._connectButton)
self._workerDelete.connectButton(self._deleteButton)
self._workerSync.connectStatusBar(self._statusBar)
self._workerConnect.connectStatusBar(self._statusBar)
self._workerDelete.connectStatusBar(self._statusBar)
self._workerDelete.connectProgressBar(self._progressBar)
self._workerConnect.finished.connect(self.slot_process_finished)
self._workerSync.finished.connect(self.slot_process_finished)
self._workerDelete.finished.connect(self.slot_process_finished)
self._calendarNames.currentTextChanged.connect(self.slot_calendar_selected)
self._progressBar.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed))
groupBoxProgress.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed))
groupBoxCalDav.setSizePolicy(QSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed))
self._url.setContentsMargins(0, 10, 0, 10)
self._password.setContentsMargins(0, 10, 0, 10)
self._user.setContentsMargins(0, 10, 0, 10)
self._calendarNames.setContentsMargins(0, 10, 0, 10)
#self.setGeometry(300, 300, 1000, 800)
self.setMinimumWidth(800)
self.setWindowTitle("MyMuell DAV GUI")
def slot_process_finished(self, result: bool, msg: str):
if result:
self._errorMessage.information(self, "info", msg)
self._syncButton.setEnabled(True)
else:
self._errorMessage.critical(self, "error", msg)
self._syncButton.setEnabled(False)
def slot_calendar_selected(self):
if self._calendarNames.currentText() != '' and self._davClient.is_connected:
self.saveSettings(0)
self._syncButton.setEnabled(True)
else:
self._syncButton.setEnabled(False)
def __fillCities(self, pattern=".+"):
self._citiesWidget.blockSignals(True)
self._citiesWidget.clear()
self._cities = self._dataModel.match_city(pattern)
for i in self._cities:
c = self._dataModel.get_city_by_index(i)
item = QListWidgetItem()
item.setData(QListWidgetItem.UserType, c["id"])
item.setText(c["name"])
self._citiesWidget.addItem(item)
self._citiesWidget.blockSignals(False)
if self._settings["mymuellcity"] != '':
items = self._citiesWidget.findItems(self._settings["mymuellcity"], Qt.MatchExactly)
if len(items) > 0:
self._citiesWidget.setCurrentItem(items[0])
def main():
app = QApplication(sys.argv)
w = MyMuell2CalDavGui()
w.show()
sys.exit(app.exec_())
if __name__ == "__main__":
main()

78
MyMuellDataModel.py Normal file
View File

@@ -0,0 +1,78 @@
import http.client
import json
import LocalDataStorage
from appdirs import *
import logging
import re
class MyMuellDataModel(object):
MyMuellHost = 'mymuell.jumomind.com'
log = logging.getLogger("MyMuellDataModel")
def __init__(self):
self.client = http.client.HTTPSConnection(MyMuellDataModel.MyMuellHost)
self.storage = LocalDataStorage.LocalDataStorage()
self.cities = self.__get_cities()
def get_cities_by_request(self):
self.client.request('GET', '/mmapp/loxone/lox.php?r=cities')
response = self.client.getresponse()
return json.loads(str(response.read(), encoding='utf-8'))
def get_events(self, city_id, area_id):
self.client.request('GET', '/mmapp/loxone/lox.php?r=dates/0&city_id={city_id}&area_id={area_id}'.format(city_id=city_id, area_id=area_id))
response = self.client.getresponse()
ret = str(response.read(), encoding='utf-8')
return json.loads(ret)
def __get_cities(self):
cities = self.storage.city_data
if cities is not None:
MyMuellDataModel.log.debug("using stored values")
return cities
else:
cities = self.get_cities_by_request()
self.storage.city_data = cities
return cities
def get_city_names(self, indices):
ret = []
for idx in indices:
e = self.get_city_by_index(idx)
if e is not None:
ret.append(e["name"])
return ret
def match_city(self, pattern):
ret = []
n = 0
for i in self.cities:
m = re.search(pattern, i["name"], re.IGNORECASE)
if m is not None:
ret.append(n)
n = n + 1
return ret
def get_city_by_index(self, idx):
if len(self.cities) < idx:
return None
return self.cities[idx]
def get_city_by_id(self, id):
for i in self.cities:
if i["id"] == id:
return i
return None
if __name__ == '__main__':
logging.basicConfig(level=logging.DEBUG)
model = MyMuellDataModel()
matches = model.match_city("eich")

65
README.md Normal file
View File

@@ -0,0 +1,65 @@
# MyMuell 2 CalDAV
Simple tool to retrive dates for garbage disposal from _MyMüll.de_ and sync it with a CalDAV Server, e.g. Nextcloud
## Motivation
_MyMüll.de_ is a web service / app provider which is used by some bavarian municipalities to digitally announce upcoming dates for garbage disposal.
Although this is basically a good idea, _MyMüll.de_ does not provide any convenience functionality to import these dates in a standard calendar to use your favourite calendar app.
Instead, users are forced to install the buggy and heavy battery draining _MyMüll.de_ smartphone app. On some android devices (e.g. Huawei), even the notifications of the app do not work reliable by default
(only after changing some settings related to app start policies)
So this tool aims to get rid of the app by parsing data from _MyMüll.de_ web service and synchronize the events with a conventional CalDAV server.
## implementation status
- all required functionality implemented for basic usage
- functional GUI written in PyQt5
- persist all settings for later use
- works on Ubuntu 20.04 and Windows 10
- works reliable in combination with Nextcloud 20 with official Calendar plugin
## ToDos
- more GUI settings
- adjustable notification triggers for upcoming events
- adjustable start time and event duration
- test/implement other CalDAV providers
- iCloud
- Google
- export the CalDAV events to a local file for manual import to a calendar
#usage
- get url of your CalDAV principal.
For Nextcloud, this `http://your.nextcloud.host/nextcloud/remote.php/dav/calendars`
- Start GUI
#Disclaimer
This tool was developed by an annoyed _MyMüll.de_ app user as a free contribute
to the open source community and is licenced under the **GPLv3** Licence.
This software does not stand in any relation to the official _MyMüll.de_ app or the company that provides/developes this service.
Although this software was developed by an experienced software developer to the best of his knowledge and belief
and was basically tested on different platforms, the author can not guarantee for the proper functionality of the software.
So the user uses this software at his own risk and he is completely responsible for any damage, security issues, data loss or any additional costs,
that might occur when using this software.

5
requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
pyqt5
caldav
vobject
appdirs
cryptography