commit 3a2241e434a245eee0766147af3db6690c8e1e67 Author: Stuce Date: Sun Nov 16 10:08:35 2025 +0100 first commit diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..35c447d --- /dev/null +++ b/Readme.md @@ -0,0 +1,33 @@ +## Goals +The goal is to make a service between a m5paper and my radicale (caldav) server. +The service will query the caldav server and modify the data in an easy to digest csv that can be sent back and forth without issues. + +## Problems to solve +Caldav format too hard to parse. Prefer to use a high level library. High level library can't run on esp32. Need a third party. + +## Justification of choices +The time invested in a proper caldav format parser would take too much time reading a specification, and not enough enjoying/learning programming. +An intermediary could prove interesting as I want to use a self-signed cert to authenticate, which I never did (everything goes trough ldap or authelia behind a reverse proxy atm). +It also forces me to learn enough about caldav to be interesting yet not cumbersome. (The rfc doc is very long, and does feel like a chore to read). +Using python is kinda in line with having a radicale server, and shouldn't add too many additional moving pieces. (Few new libraries, and they are generally stable) +Json would be the more portable/self documented choice, but I want to do the parser myself, and a correct json parser is way harder than a csv one. (Especially since it will use c language.) +For simplicity the client can only fetch from a hard coded calendar, this choice is justified by needing to specify the certificate anyway manually, so there is no point in doing a config-free interface. +Always returning the whole list even on a simple post is deemed good practice to have a restfull api that is simple, and we don't care about power consumption/ressource usage of esp32 during post, as thoses are rare and good practices on idle behavior are deemed more important. +Allows a call to fetch the specified caldav (specified in the config, not via a request, as usage) +Nix will be used as it is planned to be deployed on nixos like the rest of my web infrastructure (simply the best to not get overwhelmed by the amount of tweaking I do) + +## client facing interface specification +GET / -> gets 10 next tasks of calendar data as a csv containing an id, a boolean value (0 or 1), and a string (terminated by a \n, no carriage return windows bullshit) +POST /id -> checks the item -> returns same as GET / + +## parent server facing interface specification +GET -> find a way to get the list of VTODO on the specified calendar. +POST -> find post the updated item + +## internal logic +Filter requests by due date and truncate them. +Update an item with respecting repeating events +parse to csv +update list according to csv +conflict management policy + diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..18bf0a3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1762977756, + "narHash": "sha256-4PqRErxfe+2toFJFgcRKZ0UI9NSIOJa+7RXVtBhy4KE=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "c5ae371f1a6a7fd27823bc500d9390b38c05fa55", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5999240 --- /dev/null +++ b/flake.nix @@ -0,0 +1,76 @@ +{ + description = + "A flake for calDAVtoCSV, a middleman program between my Radicale server and my ESP32"; + + inputs = { + nixpkgs.url = + "github:nixos/nixpkgs/nixos-unstable"; # Specify the version or channel as needed + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, flake-utils, ... }: + flake-utils.lib.eachDefaultSystem (system: + let pkgs = nixpkgs.legacyPackages.${system}; + in { + packages.default = pkgs.python3.pkgs.buildPythonApplication rec { + pname = "calDAVtoCSV"; + version = "0.1.0"; + pyproject = true; + + src = ./.; + + build-system = with pkgs.python3.pkgs; [ setuptools ]; + + dependencies = with pkgs.python3.pkgs; [ caldav flask gunicorn ]; + + meta = { + description = + "middleman program between my radicale server and my esp32"; + license = pkgs.lib.licenses.mit; + maintainers = with pkgs.lib.maintainers; [ stuce-bot ]; + }; + }; + nixosModules.caldavToCsv = { config, lib, ... }: + let cfg = config.services.caldavToCsv; + in { + options.NixosModule = { + enable = lib.mkEnableOption "Enable calDAVtoCSV service"; + port = lib.mkOption { + type = lib.types.int; + default = 8000; + description = "Port on which calDAVtoCSV will listen"; + }; + url = lib.mkOption { + type = lib.types.str; + description = "url of the calendar"; + }; + calendarUsername = lib.mkOption { + type = lib.types.str; + description = "username of the calendar account"; + }; + calendarName = lib.mkOption { + type = lib.types.str; + description = + "name of the calendar we will fetch the todo items from"; + }; + calendarPasswordFile = lib.mkOption { + type = lib.types.str; + description = + "file where we need to look for password to connect, needs to be readeable by the service user"; + }; + }; + config = lib.mkIf config.myModule.enable { + systemd.services.calDAVtoCSV = { + description = "calDAV to CSV Service"; + after = [ "network.target" ]; + serviceConfig = { + ExecStart = + "${pkgs.python3.pkgs.gunicorn}/bin/gunicorn -w 1 -b 0.0.0.0:${cfg.port} main:app"; + Restart = "on-failure"; + }; + wantedBy = [ "multi-user.target" ]; + }; + }; + }; + }); +} diff --git a/main.py b/main.py new file mode 100755 index 0000000..b4a38f9 --- /dev/null +++ b/main.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python + +from flask import Flask +from caldav import DAVClient, Calendar, Principal, Todo + + +# TODO: +# [ ] settings file to not have hardcoded credentials +# [ ] change production credentials +# [ ] nix flake with complete module +# [ ] post behavior (simple) +# [ ] post behavior (concurrent protection) +# [ ] check if better http server availible or fastcgi better suited +#!/usr/bin/env python +def fetch_10_next_todos_as_csv() -> str: + def auth() -> Principal: + # TODO: on the final version, fetch it locally + client = DAVClient( + url="https://cal.stuce.ch", username="eInk", password="4Ftxy9rp8e$F*f" + ) + principal = client.principal() + return principal + + def get_calendar(principal: Principal) -> Calendar: + calendar = principal.calendar(name="Ouais le ménage") + return calendar + + def todos_to_csv(todos: list[Todo]) -> str: + result = "".join( + f"{todo.icalendar_component['uid']},{todo.icalendar_component['summary']}\n" + for todo in todos + ) + return result + + principal = auth() + calendar = get_calendar(principal) + + sorted_todos = calendar.todos(sort_keys=("due")) + cut_todos = sorted_todos[:10] + csv = todos_to_csv(cut_todos) + return csv + + +def put(todos, uid): + item = filter(lambda todo: todo.icalendar_component["uid"] == uid, todos) + + +# class SimpleHandler(BaseHTTPRequestHandler): +# def do_GET(self): +# self.send_response(200) +# self.send_header("Content-type", "text/plain") +# self.end_headers() +# response_body_str = fetch_10_next_todos_as_csv() +# response_body_utf8 = response_body_str.encode("utf8") +# self.wfile.write(response_body_utf8) +# +# +# def main(server_class=HTTPServer, port=8000): +# server_address = ("", port) +# httpd = server_class(server_address, SimpleHandler) +# httpd.serve_forever() +# +# +# if __name__ == "__main__": +# main() + + +app = Flask(__name__) + + +@app.route("/") +def send_events(): + return fetch_10_next_todos_as_csv() + + +if __name__ == "__main__": + app.run(host="0.0.0.0", port=8080)