diff --git a/hosts/default.nix b/hosts/default.nix index 27ff71f..16a5f4f 100644 --- a/hosts/default.nix +++ b/hosts/default.nix @@ -182,6 +182,7 @@ # "matrix-registration-shared-secret".owner = "matrix-synapse"; # "matrix-sliding-sync-secret".owner = "matrix-synapse"; "autistici-password".owner = "forgejo"; + "garmin-collector-environment".owner = "garmin-collector"; }; }; }; diff --git a/modules/garmin-collector/default.nix b/modules/garmin-collector/default.nix new file mode 100644 index 0000000..b4ac0e1 --- /dev/null +++ b/modules/garmin-collector/default.nix @@ -0,0 +1,46 @@ +{ + pkgs, + lib, + fleetFlake, + config, + ... +}: { + users.users.garmin-collector = { + isSystemUser = true; + group = "garmin-collector"; + extraGroups = ["garmin-collector"]; + home = "/var/lib/garmin-collector"; + }; + + users.groups.garmin-collector = {}; + + systemd.services.garmin-collector = { + description = "Garmin collector pushing to Prometheus Pushgateway"; + wantedBy = ["multi-user.target"]; + environment = { + PUSHGATEWAY_ADDRESS = config.services.prometheus.pushgateway.web.listen-address; + }; + serviceConfig = { + Group = "garmin-collector"; + User = "garmin-collector"; + WorkingDirectory = "/var/lib/garmin-collector"; + ExecStart = '' + ${lib.getExe fleetFlake.packages.${pkgs.system}.garmin-collector} + ''; + EnvironmentFile = config.age.secrets.garmin-collector-environment.path; + }; + }; + + systemd.timers."garmin-collector" = { + wantedBy = ["timers.target"]; + timerConfig = { + OnBootSec = "5m"; + OnUnitActiveSec = "4h"; + Unit = "garmin-collector.service"; + }; + }; + + environment.persistence."/persist".directories = [ + "/var/lib/garmin-collector" + ]; +} diff --git a/packages/garmin-collector/default.nix b/packages/garmin-collector/default.nix new file mode 100644 index 0000000..2e19330 --- /dev/null +++ b/packages/garmin-collector/default.nix @@ -0,0 +1,12 @@ +{ + writers, + python3Packages, + ... +}: +writers.writePython3Bin "garmin-collector" { + libraries = with python3Packages; [ + prometheus-client + garminconnect + ]; + flakeIgnore = ["E501"]; +} (builtins.readFile ./garmin-collector.py) diff --git a/packages/garmin-collector/garmin-collector.py b/packages/garmin-collector/garmin-collector.py new file mode 100644 index 0000000..f296638 --- /dev/null +++ b/packages/garmin-collector/garmin-collector.py @@ -0,0 +1,82 @@ +# !/usr/bin/env python3 + +import datetime +import os + +from garth.exc import GarthHTTPError + +from garminconnect import ( + Garmin, + GarminConnectAuthenticationError, +) + + +from prometheus_client import CollectorRegistry, push_to_gateway +from prometheus_client.core import GaugeMetricFamily + +email = os.getenv("GARMIN_EMAIL") +password = os.getenv("GARMIN_PASSWORD") +tokenstore = os.getenv("GARMINTOKENS") or "~/.garminconnect" +tokenstore_base64 = os.getenv("GARMINTOKENS_BASE64") or "~/.garminconnect_base64" +gateway_address = os.getenv("PUSHGATEWAY_ADDRESS") + +today = datetime.date.today() + + +def init_api(email=email, password=password): + """Initialize Garmin API with your credentials.""" + + try: + print( + f"Trying to login to Garmin Connect using token data from directory '{tokenstore}'...\n" + ) + + garmin = Garmin() + garmin.login(tokenstore) + except (FileNotFoundError, GarthHTTPError, GarminConnectAuthenticationError): + # Session is expired. You'll need to log in again + print( + "Login tokens not present, login with your Garmin Connect credentials to generate them.\n" + f"They will be stored in '{tokenstore}' for future use.\n" + ) + garmin = Garmin(email=email, password=password, is_cn=False) + garmin.login() + # Save Oauth1 and Oauth2 token files to directory for next login + garmin.garth.dump(tokenstore) + print( + f"Oauth tokens stored in '{tokenstore}' directory for future use. (first method)\n" + ) + # Encode Oauth1 and Oauth2 tokens to base64 string and safe to file for next login (alternative way) + token_base64 = garmin.garth.dumps() + dir_path = os.path.expanduser(tokenstore_base64) + with open(dir_path, "w") as token_file: + token_file.write(token_base64) + print( + f"Oauth tokens encoded as base64 string and saved to '{dir_path}' file for future use. (second method)\n" + ) + + return garmin + + +class GarminCollector: + def __init__(self): + super().__init__() + self.api = init_api() + + def collect(self): + try: + body = self.api.get_daily_weigh_ins(today.isoformat())["totalAverage"] + metric_gauge = GaugeMetricFamily("body_composition", "Body composition and weight", labels=["metric"]) + for k in ["weight", "bmi", "bodyFat", "bodyWater", "boneMass", "muscleMass", "physiqueRating", "visceralFat"]: + metric_gauge.add_metric([k], body[k]) + except Exception as e: + print(f"Something went wrong while fetching body composition data\n{e}") + + yield metric_gauge + + +if __name__ == "__main__": + registry = CollectorRegistry() + registry.register(GarminCollector()) + + push_to_gateway(gateway_address, job='garmin', registry=registry) diff --git a/secrets/garmin-collector-environment.age b/secrets/garmin-collector-environment.age new file mode 100644 index 0000000..efc5779 Binary files /dev/null and b/secrets/garmin-collector-environment.age differ diff --git a/secrets/secrets.nix b/secrets/secrets.nix index 8f3c4ef..fb8e4df 100644 --- a/secrets/secrets.nix +++ b/secrets/secrets.nix @@ -27,6 +27,7 @@ in "matrix-sliding-sync-secret.age".publicKeys = [ccr-ssh ccr-gpg sisko]; "forgejo-runners-token.age".publicKeys = [ccr-ssh ccr-gpg picard]; "forgejo-nix-access-tokens.age".publicKeys = [ccr-ssh ccr-gpg picard]; + "garmin-collector-environment.age".publicKeys = [ccr-ssh ccr-gpg sisko]; # WireGuard "picard-wireguard-private-key.age".publicKeys = [ccr-ssh ccr-gpg picard];