zaphyra's git: pebble-foo

proxy and preprocess apis for pebble-smartwatch apps

commit 39067f750edede2b325471756f69fec7a8167061
Author: Katja Ramona Sophie Kwast (zaphyra) <git@zaphyra.eu>
Date: Sat, 7 Nov 2026 21:41:01 +0100

init
8 files changed, 311 insertions(+), 0 deletions(-)
A
.gitignore
|
1
+
A
flake.lock
|
27
+++++++++++++++++++++++++++
A
flake.nix
|
42
++++++++++++++++++++++++++++++++++++++++++
A
lock.json
|
16
++++++++++++++++
A
nim.cfg
|
1
+
A
nix/package.nix
|
19
+++++++++++++++++++
A
pebble-foo.nimble
|
12
++++++++++++
A
src/risProxy.nim
|
193
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
diff --git a/.gitignore b/.gitignore
@@ -0,0 +1 @@
+result
diff --git a/flake.lock b/flake.lock
@@ -0,0 +1,27 @@
+{
+  "nodes": {
+    "nixpkgs": {
+      "locked": {
+        "lastModified": 1793865203,
+        "narHash": "sha256-/cpdPHaySDIyCgZI6EWngiM6jK3YLUNzv1b7lTzy6Bk=",
+        "owner": "NixOS",
+        "repo": "nixpkgs",
+        "rev": "c56a87d8e41ce69bd2af57eb35dcc117a9dfca33",
+        "type": "github"
+      },
+      "original": {
+        "owner": "NixOS",
+        "ref": "nixos-26.05",
+        "repo": "nixpkgs",
+        "type": "github"
+      }
+    },
+    "root": {
+      "inputs": {
+        "nixpkgs": "nixpkgs"
+      }
+    }
+  },
+  "root": "root",
+  "version": 7
+}
diff --git a/flake.nix b/flake.nix
@@ -0,0 +1,42 @@
+{
+
+  description = "";
+
+  inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-26.05";
+
+  outputs =
+    inputs:
+    let
+      forAllSystems =
+        function:
+        inputs.nixpkgs.lib.genAttrs
+          [
+            "x86_64-linux"
+            "aarch64-linux"
+            "aarch64-darwin"
+          ]
+          (
+            system:
+            function (
+              import inputs.nixpkgs {
+                system = system;
+                overlays = [ inputs.self.overlays.default ];
+              }
+            )
+          );
+
+    in
+    {
+
+      packages = forAllSystems (pkgs: {
+        default = pkgs.pebble-foo;
+        pebble-foo = pkgs.pebble-foo;
+      });
+
+      overlays.default = final: prev: {
+        pebble-foo = prev.callPackage ./nix/package.nix { };
+      };
+
+    };
+
+}
diff --git a/lock.json b/lock.json
@@ -0,0 +1,16 @@
+{
+  "depends": [
+    {
+      "method": "fetchzip",
+      "path": "/nix/store/sv8ydzdf509akbm3b93qlppklgvlx40x-source",
+      "rev": "5ffa3021d78e79f18482192548b8bd835002f7ab",
+      "sha256": "0h3nxnjclqd1gn9q3p9lad4i6ypwdisnbqj6rafm9155ydkc724v",
+      "url": "https://github.com/nim-lang/htmlparser/archive/5ffa3021d78e79f18482192548b8bd835002f7ab.tar.gz",
+      "ref": "v0.1.0",
+      "packages": [
+        "htmlparser"
+      ],
+      "srcDir": "src"
+    }
+  ]
+}
diff --git a/nim.cfg b/nim.cfg
@@ -0,0 +1 @@
+-d:ssl
diff --git a/nix/package.nix b/nix/package.nix
@@ -0,0 +1,19 @@
+{
+  buildNimPackage,
+  openssl,
+  sqlite,
+}:
+
+buildNimPackage {
+  name = "pebble-foo";
+  src = ../.;
+
+  lockFile = ../lock.json;
+
+  buildInputs = [
+    openssl
+    sqlite
+  ];
+
+  nimRelease = true;
+}
diff --git a/pebble-foo.nimble b/pebble-foo.nimble
@@ -0,0 +1,12 @@
+# Package
+
+version       = "0.1.0"
+author        = "katja (zaphyra)"
+description   = "pebble proxy stuff"
+license       = "MIT"
+srcDir        = "src"
+bin           = @[ "risProxy" ]
+
+# Dependencies
+requires "nim >= 1.6.0"
+requires "htmlparser >= 0.1.0"
diff --git a/src/risProxy.nim b/src/risProxy.nim
@@ -0,0 +1,193 @@
+import std/[httpclient, cookies, uri, files, paths, syncio, cgi, times, strutils, tables, json]
+import std/math
+import std/times
+import pkg/htmlparser
+import std/xmltree  # To use '$' for XmlNode
+import std/strtabs  # To access XmlAttributes
+import std/strutils # To use cmpIgnoreCase
+
+var tokenFile = Path("/tmp/csrf-token")
+var csrf: JsonNode 
+
+var dbregioClient = newHttpClient()
+
+var verkehrslageClient = newHttpClient()
+verkehrslageClient.headers["db-api-key"] = "32a84ba3-de45-46b3-a580-e3cac9591981"
+verkehrslageClient.headers["db-client-id"] = "ris-api"
+
+proc calcDistMeters(lat1, lon1, lat2, lon2: float): float = 
+  var R = 6371000.0;
+  var dLat = ((lat2 - lat1) * PI) / 180
+  var dLon = ((lon2 - lon1) * PI) / 180
+  var a =
+    sin(dLat / 2) * sin(dLat / 2) +
+    cos((lat1 * PI) / 180) *
+      cos((lat2 * PI) / 180) *
+      sin(dLon / 2) *
+      sin(dLon / 2);
+  result = (
+    R * 2.0 * arctan2(
+      sqrt(a),
+      sqrt(1 - a)
+    )
+  )
+
+proc getCsrfToken() =
+  if not fileExists(tokenFile):
+    var response = dbregioClient.request("https://wifi-landingpage.dbregio.de/", HttpGet)
+    var html = parseHtml(response.bodyStream)
+    var cookieJar = parseCookies(response.headers["set-cookie"])
+
+    for meta in html.findAll("meta"):
+        if (meta.attrs.hasKey "name") and (meta.attrs["name"] == "csrf-token"):
+          csrf = %* {
+            "token": meta.attrs["content"],
+            "cookie": cookieJar["__Host-csrf"],
+          }
+          writeFile($tokenFile, $csrf)
+  else:
+    csrf = parseFile($tokenFile)
+  
+  dbregioClient.headers["Cookie"] = "__Host-csrf=" & $csrf["cookie"]
+  dbregioClient.headers["Csrf-Token"] = $csrf["token"]
+
+proc risStationsRequest(endpoint: string, params: openArray[(string, string)]): JsonNode = 
+  var response = verkehrslageClient.request(parseUri("https://verkehrslage.bahnhof.de/api/stations") / endpoint ? params)
+  result = parseJson(response.body)
+
+proc risBoardsRequest(endpoint: string, params: openArray[(string, string)]): JsonNode = 
+  var response = dbregioClient.request(parseUri("https://wifi-landingpage.dbregio.de/api/boards") / endpoint ? params)
+
+  if response.code != Http200:
+    removeFile(tokenFile)
+    getCsrfToken()
+    response = dbregioClient.request(parseUri("https://wifi-landingpage.dbregio.de/api/boards") / endpoint ? params)
+
+  result = parseJson(response.body)
+
+proc risJourneysRequest(id: string, params: openArray[(string, string)]): JsonNode = 
+  var response = dbregioClient.request(parseUri("https://wifi-landingpage.dbregio.de/api/journeys") / id ? params)
+
+  if response.code != Http200:
+    removeFile(tokenFile)
+    getCsrfToken()
+    response = dbregioClient.request(parseUri("https://wifi-landingpage.dbregio.de/api/journeys") / id ? params)
+
+  result = parseJson(response.body)
+
+proc getStations(lat, lon: float, limit: int = 3, radius: int = 500): JsonNode = 
+  var response = risStationsRequest("/stop-places/by-position", @[
+    ("groupBy", "SALES"),
+    ("latitude", $lat),
+    ("longitude", $lon),
+    ("radius", $radius),
+    ("limit", $limit),
+  ])
+
+  result = newJArray()
+  for station in items(response["stopPlaces"]):
+    var name: string
+    if station["names"]["DE"].hasKey("speechShort"):
+      name = station["names"]["DE"]["speechShort"].getStr()
+    else:
+      name = station["names"]["DE"]["nameLong"].getStr()
+      
+    result.add(%* {
+      "name": name,
+      "id":   station["evaNumber"],
+      "dist": (int)round(calcDistMeters(
+        lat,
+        lon,
+        station["position"]["latitude"].getFloat(),
+        station["position"]["longitude"].getFloat(),
+      )),
+    })
+
+proc getDepartures(evaId: string, limit: int = 10, duration: int = 60): JsonNode =
+  var response = risBoardsRequest("/public/departures/" & evaId, @[
+    ("includeStationGroup", "true"),
+    ("maxViaStops", "0"),
+    ("sortBy", "TIME"),
+    ("duration", $duration)
+  ])
+  
+  result = newJArray()
+  for departure in items(response["departures"]):
+    var direction: string
+
+    if departure["transport"]["direction"]["text"].getStr() != "":
+      direction = departure["transport"]["direction"]["text"].getStr()
+    else:
+      direction = departure["transport"]["direction"]["stopPlaces"][0]["name"].getStr()
+
+    result.add(%* {
+      "id": departure["transport"]["journeyID"],
+      "type": departure["transport"]["type"],
+      "category": departure["transport"]["category"],
+      "line": departure["transport"]["line"],
+      "direction": direction,
+      "platform": departure["platform"],
+      "time": toUnix(parse(departure["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz").toTime()),
+      "delay": inSeconds(parse(departure["time"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz") -  parse(departure["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz")),
+      "canceled": departure["canceled"]
+    })
+
+    if (result.len == limit):
+      break
+
+proc getRoute(id: string): JsonNode =
+  var response = risJourneysRequest(id, @[
+    ("separateCancelled", "true")
+  ])
+
+  result = %* [
+    {
+      "id": response["events"][0]["stopPlace"]["evaNumber"],
+      "name": response["events"][0]["stopPlace"]["name"],
+      "platform": response["events"][0]["platform"],
+      "time": toUnix(parse(response["events"][0]["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz").toTime()),
+      "delay": inSeconds(parse(response["events"][0]["time"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz") -  parse(response["events"][0]["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz")),
+      "canceled": false,
+    }
+  ]
+  var elemNum = 0
+  for event in items(response["events"]):
+    if (event["type"].getStr() == "DEPARTURE") or (response["events"].len == elemNum):
+      result.add(%* {
+        "id": event["stopPlace"]["evaNumber"],
+        "name": event["stopPlace"]["name"],
+        "platform": event["platform"],
+        "time": toUnix(parse(event["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz").toTime()),
+        "delay": inSeconds(parse(event["time"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz") -  parse(event["timeSchedule"].getStr(), "yyyy-MM-dd'T'HH:mm:sszzz")),
+        "canceled": false,
+      })
+      elemNum = elemNum+1
+
+getCsrfToken()
+var params = readData()
+
+write(stdout, "Content-Type: application/json; charset=UTF-8\n")
+write(stdout, "Cache-Control: no-store, must-revalidate\n")
+write(stdout, "Pragma: no-cache\n")
+write(stdout, "Expires: 0\n")
+write(stdout, "\n")
+
+if params["get"] == "stations":
+  write(stdout, $getStations(
+    parseFloat(params["lat"]),
+    parseFloat(params["lon"]),
+    parseInt(params.getOrDefault("limit", "5")),
+    parseInt(params.getOrDefault("radius", "750")),
+  ))
+
+if params["get"] == "departures":
+  write(stdout, $getDepartures(
+    params["evaId"],
+    parseInt(params["limit"]),
+    parseInt(params.getOrDefault("duration", "30")),
+  ))
+
+if params["get"] == "route":
+  write(stdout, $getRoute(
+    params["id"],
+  ))