diff --git a/modules/default.nix b/modules/default.nix index a0ac22b..73d9439 100644 --- a/modules/default.nix +++ b/modules/default.nix @@ -34,6 +34,7 @@ in # All modules are imported but non-essential modules are activated by # configuration options imports = [ + ../pkgs/modules.nix ./cups.nix ./docker.nix ./fonts.nix diff --git a/pkgs/default.nix b/pkgs/default.nix index 8a03e9c..17e59b2 100644 --- a/pkgs/default.nix +++ b/pkgs/default.nix @@ -3,4 +3,6 @@ self: super: { osu-lazer = super.callPackage ./osu-lazer { inherit (super) osu-lazer; }; osu-lazer-container = super.callPackage ./osu-lazer-container { }; + + wordclock-dimmer = super.python3Packages.callPackage ./wordclock-dimmer { }; } diff --git a/pkgs/modules.nix b/pkgs/modules.nix new file mode 100644 index 0000000..c6a74d6 --- /dev/null +++ b/pkgs/modules.nix @@ -0,0 +1,5 @@ +{ + imports = [ + ./wordclock-dimmer/module.nix + ]; +} diff --git a/pkgs/wordclock-dimmer/default.nix b/pkgs/wordclock-dimmer/default.nix new file mode 100644 index 0000000..c86367a --- /dev/null +++ b/pkgs/wordclock-dimmer/default.nix @@ -0,0 +1,20 @@ +{ lib, buildPythonApplication, python3Packages }: +buildPythonApplication rec { + name = "wordclock-dimmer"; + + src = ./wordclock-dimmer.py; + + propagatedBuildInputs = with python3Packages; [ + astral + paho-mqtt + ]; + + dontUnpack = true; + format = "other"; + + installPhase = '' + install -D $src $out/bin/wordclock-dimmer + ''; + + meta.license = lib.licenses.mit; +} diff --git a/pkgs/wordclock-dimmer/module.nix b/pkgs/wordclock-dimmer/module.nix new file mode 100644 index 0000000..b456f7c --- /dev/null +++ b/pkgs/wordclock-dimmer/module.nix @@ -0,0 +1,63 @@ +{ config, lib, pkgs, ... }: +let + cfg = config.services.wordclock-dimmer; +in +{ + options.services.wordclock-dimmer = { + enable = lib.mkEnableOption "wordclock-dimmer"; + mqtt = { + user = lib.mkOption { + type = lib.types.str; + }; + password = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + passwordFile = lib.mkOption { + type = lib.types.nullOr lib.types.str; + default = null; + }; + host = lib.mkOption { + type = lib.types.str; + }; + }; + }; + + config = { + assertions = [ + { + assertion = cfg.enable -> ( + (cfg.mqtt.password != null || cfg.mqtt.passwordFile != null) + && (cfg.mqtt.password == null || cfg.mqtt.passwordFile == null) + ); + message = "One of `services.wordclock-dimmer.mqtt.password` and `services.wordclock-dimmer.mqtt.passwordFile` has to be set."; + } + ]; + + systemd.services.wordclock-dimmer = lib.mkIf cfg.enable { + wantedBy = [ "multi-user.target" ]; + after = [ "network.target" ]; + environment = with cfg.mqtt; { + WORDCLOCK_MQTT_USER = user; + WORDCLOCK_MQTT_HOST = host; + } // lib.optionalAttrs (password != null) { + WORDCLOCK_MQTT_PASSWORD = password; + } // lib.optionalAttrs (passwordFile != null) { + WORDCLOCK_MQTT_PASSWORD_FILE = passwordFile; + }; + serviceConfig = { + ExecStart = "${pkgs.wordclock-dimmer}/bin/wordclock-dimmer"; + Restart = "always"; + + # systemd-analyze --no-pager security wordclock-dimmer.service + CapabilityBoundingSet = null; + DynamicUser = true; + PrivateUsers = true; + ProtectHome = true; + RestrictAddressFamilies = [ "AF_INET" "AF_INET6" ]; + RestrictNamespaces = true; + SystemCallFilter = "@system-service"; + }; + }; + }; +} diff --git a/pkgs/wordclock-dimmer/wordclock-dimmer.py b/pkgs/wordclock-dimmer/wordclock-dimmer.py new file mode 100755 index 0000000..e264262 --- /dev/null +++ b/pkgs/wordclock-dimmer/wordclock-dimmer.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +from astral.location import Location, LocationInfo +from math import cos, pi +from time import sleep +import datetime +import os +import paho.mqtt.client as mqtt +import sys + + +def time_as_float_hours(time: datetime.time) -> float: + return time.hour + time.minute / 60 + time.second / 3600 + + +def set_color(client: mqtt.Client, red: int, green: int, blue: int, retain=True): + # my wordclock’s red and green LED’s seem to be switched + client.publish("wordclock/color/red", green, retain=True) + client.publish("wordclock/color/green", red, retain=True) + client.publish("wordclock/color/blue", blue, retain=True) + + +def get_color_for_time(time: datetime.time, base=(60, 60, 60)) -> (int, int, int): + if time.hour >= 22 or time.hour < 7: + # night mode: dim red + return (3, 0, 0) + else: + # day mode: calculated depending on sun (https://www.desmos.com/calculator/nrseefnaom) + now = time_as_float_hours(time) + + # returns at least (3, 3, 3) + min_factors = (3 / base[0], 3 / base[1], 3 / base[2]) + + rate = 5 + + location_info = LocationInfo( + timezone="Europe/Berlin", latitude=49.52, longitude=10.18 + ) + location = Location(location_info) + sunrise = time_as_float_hours(location.sunrise().time()) + sunset = time_as_float_hours(location.sunset().time()) + + factors = [] + for min_factor in min_factors: + factor = min_factor + if now > sunrise and now < sunset: + factor = 1 - ( + (1 - min_factor) * cos((pi / (sunrise - sunset)) * (now - sunset)) + ) ** (2 * rate) + factors.append(factor) + + return ( + round(base[0] * factors[0]), + round(base[1] * factors[1]), + round(base[2] * factors[2]), + ) + + +def update(client: mqtt.Client): + time = datetime.datetime.now().time() + color = get_color_for_time(time) + print(f"{time}: setting color to {color}") + sys.stdout.flush() + set_color(client, *color) + pass + + +client = mqtt.Client("wordclock.py") + +user = os.environ["WORDCLOCK_MQTT_USER"] +try: + password = os.environ["WORDCLOCK_MQTT_PASSWORD"] +except KeyError: + with open(os.environ["WORDCLOCK_MQTT_PASSWORD_FILE"]) as f: + password = f.read().rstrip() +host = os.environ["WORDCLOCK_MQTT_HOST"] + +client.username_pw_set(user, password) +client.connect(host, 1883, 60) + +while True: + update(client) + sleep(300)