zaphyra's git: nixfiles

zaphyra and void's nixfiles

commit ce0a666733b217e772b0310640bba9551b4fcdda
parent 26e2e7cf54e5b75015ee2fe8ce81298e5b2e9460
Author: Katja (zaphyra) <git@ctu.cx>
Date: Thu, 29 May 2025 23:54:01 +0200

config/nixos/modules/presets/katja: add `smarthome`
13 files changed, 791 insertions(+), 4 deletions(-)
A
config/nixos/modules/presets/katja/smarthome/enable.nix
|
32
++++++++++++++++++++++++++++++++
A
config/nixos/modules/presets/katja/smarthome/influxdb2.nix
|
59
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
config/nixos/modules/presets/katja/smarthome/mosqitto.nix
|
65
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
config/nixos/modules/presets/katja/smarthome/mqttWebUI.nix
|
71
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
config/nixos/modules/presets/katja/smarthome/telegraf.nix
|
60
++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
config/nixos/modules/presets/katja/smarthome/zigbee2mqtt.nix
|
113
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
M
flake.lock
|
37
+++++++++++++++++++++++++++++++++++++
M
flake.nix
|
6
++++++
M
hosts/polaris/default.nix
|
11
+++++++++--
A
resources/katja/mqttWebUI/config.nix
|
217
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
resources/katja/mqttWebUI/extra-css.css
|
104
+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A
resources/katja/rootCA.crt
|
11
+++++++++++
M
secrets/polaris.yaml
|
9
+++++++--
diff --git a/config/nixos/modules/presets/katja/smarthome/enable.nix b/config/nixos/modules/presets/katja/smarthome/enable.nix
@@ -0,0 +1,32 @@
+{
+  inputs,
+  pov,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath pov config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg.enable {
+    modules.presets.katja.smarthome = {
+      mosqitto = true;
+      zigbee2mqtt = true;
+      mqttWebUI = true;
+      influxdb2 = true;
+      telegraf = true;
+    };
+  };
+
+}
diff --git a/config/nixos/modules/presets/katja/smarthome/influxdb2.nix b/config/nixos/modules/presets/katja/smarthome/influxdb2.nix
@@ -0,0 +1,59 @@
+{
+  povSelf,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath povSelf config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg {
+    dns.zones."zaphyra.eu".subdomains."influx.home.infra".AAAA = [ hostConfig.networking.ip6Address ];
+
+    sops.secrets = {
+      "resticPasswords/influxdb2" = { };
+      "environments/influxdb2" = { };
+    };
+
+    modules.services.resticBackup.paths.mail = {
+      environmentFile = config.sops.secrets."environments/influxdb2".path;
+      passwordFile = config.sops.secrets."resticPasswords/influxdb2".path;
+      user = "influxdb2";
+      influxBuckets = [ "mqttData" ];
+    };
+
+    systemd.services.influxdb2 =
+      let
+        port = lib.last (builtins.split "]:" config.services.influxdb2.settings.http-bind-address);
+      in
+      {
+        serviceConfig.ExecStartPost = "${pkgs.bash}/bin/bash -c 'until ${pkgs.netcat}/bin/nc -z ::1 ${port}; do sleep 0.2; done'";
+      };
+
+    services = {
+      influxdb2.enable = true;
+      influxdb2.settings.http-bind-address = "[::1]:8086";
+
+      nginx.enable = true;
+      nginx.virtualHosts."influx.${config.networking.domain}" = {
+        useACMEHost = config.networking.fqdn;
+        forceSSL = true;
+        kTLS = true;
+        locations."/".proxyPass =
+          "http://${toString config.services.influxdb2.settings.http-bind-address}/";
+      };
+    };
+  };
+
+}
diff --git a/config/nixos/modules/presets/katja/smarthome/mosqitto.nix b/config/nixos/modules/presets/katja/smarthome/mosqitto.nix
@@ -0,0 +1,65 @@
+{
+  povSelf,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath povSelf config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg {
+    systemd.services = {
+      mosquitto.requires = [ "mosquittoFixPerms.service" ];
+      mosquittoFixPerms.script = ''
+        chown -R mosquitto:mosquitto /etc/mosquitto;
+      '';
+    };
+
+    services.mosquitto = {
+      enable = true;
+      persistence = false;
+      settings.max_keepalive = 60;
+      listeners = [
+        {
+          port = 1883;
+          omitPasswordAuth = true;
+          users = { };
+          settings = {
+            allow_anonymous = true;
+          };
+          acl = [
+            "topic readwrite #"
+            "pattern readwrite #"
+          ];
+        }
+
+        {
+          address = "::1";
+          port = 9005;
+          omitPasswordAuth = true;
+          users = { };
+          settings = {
+            protocol = "websockets";
+            allow_anonymous = true;
+          };
+          acl = [
+            "topic readwrite #"
+            "pattern readwrite #"
+          ];
+        }
+      ];
+    };
+  };
+
+}
diff --git a/config/nixos/modules/presets/katja/smarthome/mqttWebUI.nix b/config/nixos/modules/presets/katja/smarthome/mqttWebUI.nix
@@ -0,0 +1,71 @@
+{
+  inputs,
+  povSelf,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath povSelf config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg {
+    dns.zones."zaphyra.eu".subdomains."smart.home.infra".AAAA = [ hostConfig.networking.ip6Address ];
+
+    services.nginx = {
+      enable = true;
+      virtualHosts."smart.${config.networking.domain}" = {
+        useACMEHost = config.networking.fqdn;
+        forceSSL = true;
+        kTLS = true;
+        extraConfig = ''
+          ssl_client_certificate ${inputs.self.resources.katja.rootCA};
+          ssl_verify_client optional;
+        '';
+        locations = {
+          "/" = {
+            root = "${
+              pkgs.buildEnv {
+                name = "mqtt-webui-env";
+                paths = [
+                  pkgs.mqtt-webui
+                  (pkgs.writeTextDir "extra.css" (builtins.toJSON inputs.self.resources.katja.mqttWebUI.extra-css))
+                  (pkgs.writeTextDir "config.json" (
+                    builtins.toJSON (import inputs.self.resources.katja.mqttWebUI.config)
+                  ))
+                ];
+              }
+            }/";
+            extraConfig = ''
+              location ~ ^/(?!(favicon-512x512\.png|manifest\.json)) {
+                if ($ssl_client_verify != SUCCESS) {
+                    return 403;
+                }
+              }
+            '';
+          };
+          "/mqtt" = {
+            proxyPass = "http://[::1]:9005";
+            proxyWebsockets = true;
+            extraConfig = ''
+              if ($ssl_client_verify != SUCCESS) {
+                  return 403;
+              }
+            '';
+          };
+        };
+      };
+    };
+  };
+
+}
diff --git a/config/nixos/modules/presets/katja/smarthome/telegraf.nix b/config/nixos/modules/presets/katja/smarthome/telegraf.nix
@@ -0,0 +1,60 @@
+{
+  povSelf,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath povSelf config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg {
+    sops.secrets."environments/telegraf" = {
+      owner = "telegraf";
+    };
+
+    services.telegraf = {
+      enable = true;
+      environmentFiles = [ config.sops.secrets."environments/telegraf".path ];
+      extraConfig = {
+        inputs = {
+          mqtt_consumer = {
+            servers = [ "tcp://[::1]:1883" ];
+            topics = [
+              "zigbee2mqtt/tuya_sensor_fridge"
+              "zigbee2mqtt/tuya_sensor_bathroom"
+              "zigbee2mqtt/tuya_sensor_sleepingroom"
+              "zigbee2mqtt/tuya_sensor_l2"
+            ];
+            data_format = "json";
+            fielddrop = [
+              "newBatt"
+              "weakBatt"
+            ];
+          };
+        };
+        outputs = {
+          influxdb_v2 = [
+            {
+              urls = [ "http://${toString config.services.influxdb2.settings.http-bind-address}/" ];
+              organization = "smarthome";
+              bucket = "mqttData";
+              token = "\${INFLUX_TOKEN_MQTTDATA}";
+            }
+          ];
+        };
+      };
+    };
+  };
+
+}
diff --git a/config/nixos/modules/presets/katja/smarthome/zigbee2mqtt.nix b/config/nixos/modules/presets/katja/smarthome/zigbee2mqtt.nix
@@ -0,0 +1,113 @@
+{
+  inputs,
+  povSelf,
+  pkgs,
+  lib,
+  config,
+  hostConfig,
+  ...
+}:
+let
+  inherit (lib) types;
+  cfg = lib.getAttrFromPath povSelf config;
+
+in
+{
+
+  option = {
+    type = types.bool;
+    default = false;
+  };
+
+  config = lib.mkIf cfg {
+    dns.zones."zaphyra.eu".subdomains."zigbee2mqtt.home.infra".AAAA = [
+      hostConfig.networking.ip6Address
+    ];
+
+    sops.secrets."zigbee2mqttSecrets.yaml" = {
+      owner = "zigbee2mqtt";
+      key = "zigbee2mqttSecrets";
+    };
+
+    systemd.services.zigbee2mqtt = {
+      requires = [ "mosquitto.service" ];
+      after = [ "mosquitto.service" ];
+      serviceConfig = {
+        Restart = lib.mkForce "always";
+        RuntimeMaxSec = "1d";
+      };
+    };
+
+    services = {
+      udev.extraRules = ''
+        SUBSYSTEM=="tty", ATTRS{idVendor}=="10c4", ATTRS{serial}=="00_12_4B_00_25_9B_C1_FC", SYMLINK+="zigbee0"
+        ATTR{idVendor}=="0451", ATTR{idProduct}=="16a8", ENV{ID_MM_DEVICE_IGNORE}="1"
+        SUBSYSTEM=="tty", ATTRS{idVendor}=="0451", ATTRS{idProduct}=="16a8", SYMLINK+="zigbee0"
+      '';
+
+      nginx = {
+        enable = true;
+        virtualHosts."zigbee2mqtt.${config.networking.domain}" = {
+          useACMEHost = config.networking.fqdn;
+          forceSSL = true;
+          kTLS = true;
+          extraConfig = ''
+            ssl_client_certificate ${inputs.self.resources.katja.rootCA};
+            ssl_verify_client on;
+          '';
+          locations."/" = {
+            proxyPass = "http://[::1]:${toString config.services.zigbee2mqtt.settings.frontend.port}";
+            proxyWebsockets = true;
+          };
+        };
+      };
+
+      zigbee2mqtt = {
+        enable = true;
+        package = pkgs.zigbee2mqtt;
+        settings = {
+          homeassistant = false;
+          permit_join = false;
+
+          mqtt = {
+            base_topic = "zigbee2mqtt";
+            server = "mqtt://[::1]";
+          };
+
+          serial = {
+            port = "/dev/zigbee0";
+            disable_led = true;
+          };
+
+          frontend = {
+            port = 8422;
+            host = "::1";
+          };
+
+          advanced = {
+            log_level = "info";
+            log_output = [ "console" ];
+            channel = 26;
+            network_key = "!${config.sops.secrets."zigbee2mqttSecrets.yaml".path} network_key";
+          };
+
+          device_options.retain = true;
+          devices = {
+            "0x84fd27fffe6b9ddd".friendly_name = "ikea_lamp_hallway";
+            "0x94deb8fffe52e639".friendly_name = "ikea_lamp_rgb";
+            "0x84fd27fffe44369e".friendly_name = "ikea_lamp_sleepingroom";
+            "0x84fd27fffea515fc".friendly_name = "ikea_lamp_livingroom";
+
+            "0xa4c138da0f6d23de".friendly_name = "tuya_led_stripe_desk";
+
+            "0xa4c1389d5f391891".friendly_name = "tuya_sensor_fridge";
+            "0xa4c13809f76bcdc2".friendly_name = "tuya_sensor_bathroom";
+            "0xa4c13882b76fa1ac".friendly_name = "tuya_sensor_sleepingroom";
+            "0xa4c138ebeae2efd2".friendly_name = "tuya_sensor_l2";
+          };
+        };
+      };
+    };
+  };
+
+}
diff --git a/flake.lock b/flake.lock
@@ -554,6 +554,21 @@
         "type": "github"
       }
     },
+    "impermanence": {
+      "locked": {
+        "lastModified": 1737831083,
+        "narHash": "sha256-LJggUHbpyeDvNagTUrdhe/pRVp4pnS6wVKALS782gRI=",
+        "owner": "nix-community",
+        "repo": "impermanence",
+        "rev": "4b3e914cdf97a5b536a889e939fb2fd2b043a170",
+        "type": "github"
+      },
+      "original": {
+        "owner": "nix-community",
+        "repo": "impermanence",
+        "type": "github"
+      }
+    },
     "lanzaboote": {
       "inputs": {
         "crane": "crane_3",

@@ -625,6 +640,26 @@
         "url": "https://git.lix.systems/lix-project/nixos-module/archive/main.tar.gz"
       }
     },
+    "mqttWebUI": {
+      "inputs": {
+        "nixpkgs": [
+          "nixpkgs"
+        ]
+      },
+      "locked": {
+        "lastModified": 1741208595,
+        "narHash": "sha256-Eel1FQBWRPYy3oToZT9xz5M3f0rfxjlGOizyCHxWrwU=",
+        "ref": "refs/heads/main",
+        "rev": "071d9f35d74c02948e93afd48906c181888ebeec",
+        "revCount": 20,
+        "type": "git",
+        "url": "https://git.zaphyra.eu/mqtt-webui"
+      },
+      "original": {
+        "type": "git",
+        "url": "https://git.zaphyra.eu/mqtt-webui"
+      }
+    },
     "nix-filter": {
       "locked": {
         "lastModified": 1731533336,

@@ -870,9 +905,11 @@
         "haumea": "haumea",
         "homeManager": "homeManager",
         "homeManagerUnstable": "homeManagerUnstable",
+        "impermanence": "impermanence",
         "lanzaboote": "lanzaboote",
         "lix": "lix",
         "lixModule": "lixModule",
+        "mqttWebUI": "mqttWebUI",
         "nixStd": "nixStd",
         "nixSystemsDefault": "nixSystemsDefault",
         "nixpkgs": "nixpkgs_3",
diff --git a/flake.nix b/flake.nix
@@ -66,6 +66,7 @@
         inputs.gpxMap.overlays.default
         inputs.oeffisearch.overlays.default
         inputs.things.overlays.default
+        inputs.mqttWebUI.overlays.default
       ];
 
       hosts = loadDir importLoader ./hosts;

@@ -102,6 +103,7 @@
                 ]
             )
 
+            inputs.impermanence.nixosModules.default
             inputs.lixModule.nixosModules.default
             inputs.lanzaboote.nixosModules.lanzaboote
             inputs.sopsNix.nixosModules.sops

@@ -187,6 +189,7 @@
     nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05";
     nixpkgsUnstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable";
 
+    impermanence.url = "github:nix-community/impermanence";
     nixStd.url = "github:chessai/nix-std";
 
     dnsNix.url = "git+https://git.zaphyra.eu/dns.nix";

@@ -259,6 +262,9 @@
     things.url = "git+https://git.zaphyra.eu/things";
     things.inputs.nixpkgs.follows = "nixpkgs";
 
+    mqttWebUI.url = "git+https://git.zaphyra.eu/mqtt-webui";
+    mqttWebUI.inputs.nixpkgs.follows = "nixpkgs";
+
     firefoxGnomeTheme.flake = false;
     firefoxGnomeTheme.url = "github:rafaelmardojai/firefox-gnome-theme/v137";
   };
diff --git a/hosts/polaris/default.nix b/hosts/polaris/default.nix
@@ -33,8 +33,14 @@
     }:
     {
 
-      boot.initrd.systemd.emergencyAccess = true;
-      boot.kernelPackages = pkgs.linuxPackages_latest;
+      boot = {
+        kernelPackages = pkgs.linuxPackages_latest;
+
+        # seems to make realtek ethernet faster?
+        kernelParams = [ "pcie_aspm=off" ];
+
+        initrd.systemd.emergencyAccess = true;
+      };
 
       sops.secrets = {
         "resticEnv/novus" = {

@@ -60,6 +66,7 @@
           katja = {
             enable = true;
             router.enable = true;
+            smarthome.enable = true;
             syncthing.enable = false;
           };
         };
diff --git a/resources/katja/mqttWebUI/config.nix b/resources/katja/mqttWebUI/config.nix
@@ -0,0 +1,217 @@
+let
+
+  Switch = name: topic: {
+    title = name;
+    type = "switch";
+    icon = "icons/power_button.png";
+    topic.get = topic;
+    topic.set = "${topic}/set";
+    transform.get = "return (message.state == 'ON') ? true : false";
+    transform.set = "return JSON.stringify({state: (input) ? 'ON' : 'OFF'})";
+  };
+
+  BrighnessSlider = name: topic: {
+    title = name;
+    type = "slider";
+    icon = "icons/bulb.png";
+    sliderMinValue = 0;
+    sliderMaxValue = 254;
+    sliderStepValue = 1;
+    topic.get = topic;
+    topic.set = "${topic}/set";
+    transform.get = "return message.brightness";
+    transform.set = "return JSON.stringify({brightness: Number(input)})";
+  };
+
+  ColorTemperatureSlider = name: topic: {
+    title = name;
+    type = "slider";
+    icon = "icons/bulb.png";
+    sliderMinValue = 250;
+    sliderMaxValue = 454;
+    sliderStepValue = 1;
+    topic.get = topic;
+    topic.set = "${topic}/set";
+    transform.get = "return message.color_temp";
+    transform.set = "return JSON.stringify({color_temp: Number(input)})";
+  };
+
+  DimmableLamp = name: topic: {
+    title = name;
+    items = [
+      (Switch "Power" topic)
+      (BrighnessSlider "Brighness" topic)
+    ];
+  };
+
+  WhiteSpectrumLamp = name: topic: {
+    title = name;
+    items = [
+      (Switch "Power" topic)
+      (BrighnessSlider "Brighness" topic)
+      (ColorTemperatureSlider "Color Temperature" topic)
+    ];
+  };
+
+  ColorSpectrumLamp = name: topic: {
+    title = name;
+    items = [
+      (Switch "Power" topic)
+      (BrighnessSlider "Brighness" topic)
+      (ColorTemperatureSlider "Color Temperature" topic)
+
+      {
+        title = "Color";
+        type = "select";
+        icon = "icons/bulb.png";
+        topic.get = topic;
+        topic.set = "${topic}/set";
+        transform.get = "return message.color.x + ','+message.color.y";
+        transform.set = "return JSON.stringify({color: {x: input.split(',')[0], y: input.split(',')[1]}})";
+        selectOptions = [
+          {
+            label = "Red";
+            value = "0.71,0.26";
+          }
+          {
+            label = "Green";
+            value = "0.19,0.78";
+          }
+          {
+            label = "Blue";
+            value = "0.09,0.13";
+          }
+        ];
+      }
+    ];
+  };
+
+in
+{
+
+  appName = "Smart-Home";
+  extraCSS = "/extra.css";
+  disableAuth = true;
+  pages = [
+    {
+      id = "mainpage";
+      icon = "favicon-512x512.png";
+      title = "Smart-Home";
+      sections = [
+
+        (WhiteSpectrumLamp "Hallway" "zigbee2mqtt/ikea_lamp_hallway")
+
+        (WhiteSpectrumLamp "Living room" "zigbee2mqtt/ikea_lamp_livingroom")
+
+        (WhiteSpectrumLamp "Sleeping room" "zigbee2mqtt/ikea_lamp_sleepingroom")
+
+        (DimmableLamp "Desk" "zigbee2mqtt/tuya_led_stripe_desk")
+
+        (ColorSpectrumLamp "RGB Lamp" "zigbee2mqtt/ikea_lamp_rgb")
+
+        {
+          title = "Temperature-Sensors";
+          items = [
+            {
+              title = "Fridge";
+              type = "text";
+              topic = "zigbee2mqtt/tuya_sensor_fridge";
+              icon = "icons/temperature.png";
+              transform = "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C - ' + message.humidity + ' %'";
+            }
+            {
+              title = "Bathroom";
+              type = "text";
+              topic = "zigbee2mqtt/tuya_sensor_bathroom";
+              icon = "icons/temperature.png";
+              transform = "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C - ' + message.humidity + ' %'";
+            }
+
+            {
+              title = "Door";
+              type = "text";
+              topic = "zigbee2mqtt/tuya_sensor_l";
+              icon = "icons/temperature.png";
+              transform = "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C - ' + message.humidity + ' %'";
+            }
+            {
+              title = "Bed";
+              type = "text";
+              topic = "zigbee2mqtt/tuya_sensor_l2";
+              icon = "icons/temperature.png";
+              transform = "return Math.round((message.temperature + Number.EPSILON) * 100) / 100 + ' °C - ' + message.humidity + ' %'";
+            }
+
+          ];
+        }
+
+        {
+          items = [
+            {
+              title = "Departures";
+              type = "text";
+              icon = "icons/electric_range.png";
+              link = "#departures";
+            }
+            {
+              title = "Grafana-Dashboard";
+              type = "text";
+              icon = "icons/sun.png";
+              link = "https://grafana.infra.katja.wtf/d/FRDYqjEGz/smarthome-influx?orgId=1&refresh=5s";
+            }
+          ];
+        }
+
+      ];
+    }
+
+    {
+      id = "departures";
+      title = "Departures";
+      sections = [
+        {
+          items = [
+            {
+              type = "html";
+              topic = "departures2mqtt";
+              html = "<div class=\"loader\"></div>";
+              transform = ''
+                clearInterval(globalThis.departuresUpdater)
+                globalThis.departuresUpdater = setInterval(() => {
+                  let element = document.querySelector('[data-last-updated]');
+                  element.textContent = 'Last Update: ' + (Math.floor(Date.now() / 1000) - element.dataset.lastUpdated) + ' seconds ago';
+                }, 10000)
+
+                let output = "";
+
+                Object.entries(message.departures).forEach((data) => {
+                   output += '<div class="departures"><div class="table-column table-title">' + data[0] + '</div><div class="table">';
+
+                   output += '<div class="table-row line-column"><div class="table-column table-heading">Linie</div>';
+                   data[1].forEach((departure) => output += '<div class="table-column">' + departure.line + '</div>');
+                   output += '</div>';
+
+                   output += '<div class="table-row direction-column"><div class="table-column table-heading">Richtung</div>';
+                   data[1].forEach((departure) => output += '<div class="table-column">' + departure.direction + '</div>');
+                   output += '</div>';
+
+                   output += '<div class="table-row dep-column"><div class="table-column table-heading">Abfahrt</div>';
+                   data[1].forEach((departure) => output += '<div class="table-column">' + (Number(departure.departure_in) ? departure.departure_in + " min" : "sofort") + '</div>');
+                   output += '</div>';
+
+                   output += '</div></div>';
+                });
+
+                output += '<div class="lastUpdated" data-last-updated="' + message.lastUpdated + '">Last Update: ' + (Math.floor(Date.now() / 1000) - message.lastUpdated) + ' seconds ago</div>';
+
+                return output;
+              '';
+            }
+          ];
+        }
+      ];
+    }
+
+  ];
+
+}
diff --git a/resources/katja/mqttWebUI/extra-css.css b/resources/katja/mqttWebUI/extra-css.css
@@ -0,0 +1,103 @@
+section > div[data-mqtt-topic="departures2mqtt"] {
+	background: #444;
+	padding: 0;
+}
+
+section > div[data-mqtt-topic="grafana"] {
+	background: #444;
+	padding: 0;
+}
+
+section > div[data-mqtt-topic="grafana"] iframe {
+	border-radius: inherit;
+	width: 100%;
+	height: 20rem;
+	margin-bottom: -6px;
+}
+
+section > div[data-mqtt-topic="departures2mqtt"] * {
+	box-sizing: unset;
+}
+
+section > div[data-mqtt-topic="departures2mqtt"] > .loader {
+	margin: 0 auto;
+}
+
+.lastUpdated {
+	text-align: center;
+	font-style: italic;
+}
+
+.table {
+	display: flex;
+	flex-direction: row;
+}
+
+.table-row {
+	display: flex;
+	flex-direction: column;
+	width: 100%;
+}
+
+.table-column {
+	padding: .5em;
+	height: 1em;
+	display: flex;
+	flex-direction: column;
+	justify-content: center;
+}
+
+.table-column {
+	background-color: #ddd;
+}
+
+.line-column {
+	width: 20%;
+}
+
+.direction-column {
+	width: 60%;
+}
+
+.dep-column {
+	width: 20%;
+}
+
+.table {
+	flex-wrap: wrap;
+	padding: 20px;
+	padding-top: 10px;
+	background: #444;
+}
+
+.table-column {
+	background: #444;
+	color: white;
+}
+
+.table-column:not(.table-heading):not(.table-title) {
+	font-family: monospace;
+	font-size: 15px;
+	background: #222;
+	border-bottom: 1px solid grey;
+	color: #ffaa00;
+}
+
+.table-row:last-child {
+	border-bottom: 0;
+}
+
+.table-title {
+	padding: .85em;
+	padding-bottom: 0;
+	font-size: 1.4em;
+}
+
+.table-heading {
+	border-left: 2px solid white;
+}
+
+.table-empty {
+	flex-basis: 100%;
+	text-align: center;
+}+
\ No newline at end of file
diff --git a/resources/katja/rootCA.crt b/resources/katja/rootCA.crt
@@ -0,0 +1,11 @@
+-----BEGIN CERTIFICATE-----
+MIIBkjCCATmgAwIBAgIQHcFZjp65wx2iQWzi8TZ4QDAKBggqhkjOPQQDAjAoMQ4w
+DAYDVQQKEwVjdHVjeDEWMBQGA1UEAxMNY3R1Y3ggUm9vdCBDQTAeFw0yNTAzMDkw
+ODI2NDJaFw0zNTAzMDcwODI2NDJaMCgxDjAMBgNVBAoTBWN0dWN4MRYwFAYDVQQD
+Ew1jdHVjeCBSb290IENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEs9Q/rCru
+O1gqNosldxZluPX4UGBwtxjEAHN53axv1IeGzvzBXHzVggbGxnyCza4Dfe21jVTw
+OPMsH7E1nhVw26NFMEMwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8C
+AQEwHQYDVR0OBBYEFKqGuFB52x2mJgYlUB9UeG3jCvpUMAoGCCqGSM49BAMCA0cA
+MEQCIGfh/cdpuZ4+mdmAwtKH+s8XPy/OoBJBbdI45jDkqJCpAiBhQy4n+2jR9tJo
+7bdNaN1dvCvhqgP8OFETk6g3ir8THQ==
+-----END CERTIFICATE-----
diff --git a/secrets/polaris.yaml b/secrets/polaris.yaml
@@ -1,7 +1,12 @@
 acmeTSIGKey: ENC[AES256_GCM,data:qDk3XM2ZqPqdAbGTkLO1x/ZYK3JNcJxQReYSFz+2Mcpr4B8zeM5Clv2DYZds4g2MhRZngx3zCTabYK/NI3SXlA==,iv:oAP9NmLpI/5dbVjUT0Swyv5ARheY7SYi21+Bbhn6jLQ=,tag:lqjWTXma3Zs6BDu7iVaLSw==,type:str]
 wireguardPrivKey: ENC[AES256_GCM,data:R8JIxdwu+SjhT5n4ab582jnZ3Ed27FVKk999mrIWNwtF1FsMR7XcBoSpbag=,iv:h/GOtJO7hFe2BYqzfwJ5mpLUUzq1eoEJsd2iDRQDv04=,tag:QS/52ZI260xH3a9Md0r2RA==,type:str]
+zigbee2mqttSecrets: ENC[AES256_GCM,data:zLoBtmNionxMxg/5PHRL9UvBLAHvpnh/wj4UA0cZzt7CZWna6az4DiuKmaWUrMcvoc+jpng+VLHXF5Z3kv0jRExwU+zCE9dpJQRUNRKHpXfeO3MoiEE=,iv:+EaznAMg4RMVtQPC+S3LQcbDQ/CXU2nD9n00YFw+o+0=,tag:ZokPcnrfdPuZupq8uHF7WA==,type:str]
 environments:
     pppd: ENC[AES256_GCM,data:ajmyLnfZQej+SN4Bhihupo7IANxTBx8HstT+KoO8pwcQwpwQBpTYUKVOEDZYpPwrRBEbF7wzZ9Col+UGzymHSUB5,iv:I0aHwKhZSC7qOOk382XMttJI+fl1pWfPmU5IcGAqR/w=,tag:xUx49Aw51a3JF4mUvxplxw==,type:str]
+    telegraf: ENC[AES256_GCM,data:ek6j3jXLtQK2K2s4amUZbt6EyKnqvyeaBR1mZX8oeT7B1kLzH25yb2+bjDFRIYwVQGO2o2lW3NU9MZRiMRSLaktHdzey0CxZJNscpJXGAWqYZweu/Q0g0sxV6/+mxoooskK/zL4/s4G3J8D6zlg8foEntPaeX73HaxGZf42izrABhRKgwCZZYT2R2BVQ7DnEQ9HzNRRs5dra49ErZ41As+ZBPCNd/+gUEMkg96fzSruC5dCKX/X1O5uZNowE2C+2dOXi5ZxmeWVEKphUsgE5zMxCFYZwXTvX9uYj549JT3Gs6LA=,iv:s7ks7dd5gq9C5YzWI83GACzhv5KVVH8UXJGDeVUojeM=,tag:AGsfFjre3VvwNGBKJ3Fn6w==,type:str]
+    influxdb2: ENC[AES256_GCM,data:GVEs92BPKe8Lv4VxVBnbjTTVHszVCZHmd/gTw/K4UscX4efwjldknLPOqAEPNF7SdoEEbX17dSA0BL8Gj68j4rhTPJMGluWOprfDAZ+gMpEglfbfU4P2J0WgrbgLZDj53kBx95NsysVWGaR14jyff0S5Ay5YgSo=,iv:FAjaA/VsW1KNRbsoyv79jW4ooRaYMUs7SiWJ1VQBlqU=,tag:iC+wAFXR8EcKd67OL+lIIw==,type:str]
+resticPasswords:
+    influxdb2: ENC[AES256_GCM,data:OBGzj5IcuM7bvb2k/wg3qwisA5TC9qMNWU4KDP2OEUs=,iv:DqTNEVQDpmRB6MOKvyKJ6YuWdKpIIt1q6yfeTaFSiZI=,tag:rTPtLkP0d4mVb0fvSHIzlw==,type:str]
 sops:
     age:
         - recipient: age1x038e3tmhkuddn3ez9hcf80ehs0mvgfucxmnynrfzgccask4vu6sqlmr8v

@@ -13,8 +18,8 @@ sops:
             ektXa2ZUZFlNWEZyOURsSHB3WUs1eTgK3AdiBxPM5DAWJ3qlA8P7NAMgvlngxuIm
             jno7otlT8WpEuugJ2LAwNDXCsqoyntoncDa5jHXHKjsQsyW+fQlb3w==
             -----END AGE ENCRYPTED FILE-----
-    lastmodified: "2025-05-29T12:52:44Z"
-    mac: ENC[AES256_GCM,data:TecAq7hRccJ4PLMXKFZmXuNQekjAzx1R0o7QLyfps8yZ35bf3zpdRc5aIOpFo4PJYjTRoTjUtfJ0DiVqmlAuxwJj+aLU+J8kCJdrNLSvrxyRac0l8f/YU7h2USpyOPWKE731HBfv2HoAqezZrE8z6vN+mlwpylIfxzd0+1qUuoY=,iv:yusXC2te+SKTQyrgVe1bI98RLRH9jlKMK8y54TzxHp8=,tag:e5nMVDlKHE5AkkDejKO5sQ==,type:str]
+    lastmodified: "2025-05-29T20:49:24Z"
+    mac: ENC[AES256_GCM,data:RWsh7KT8dU9ZWFs4663lH6rG6FApbrJdSl9kbKLRVlPU/d9q20fnT2FKylIIsSluEMnQXUCF1AVaPu6sWTXGNXDMYYLDcJyRKMzQ98wSDPrfcCl1e/S9bWA7Tr868XyEhb3DjxEIVrutYH78dxFIL07gXnKekymx6SZ0QwrUOpo=,iv:uTNhtIcpQaPMytTLuGwmZ9qfpPwClaYGIHfSMralw00=,tag:L1cnJZZEt47fwfdc+06gTw==,type:str]
     pgp:
         - created_at: "2025-05-29T11:46:02Z"
           enc: |-