Table of contents
Open Table of contents
Background
Before there were iPad kids, there were TV kids… I was one of those kids.
Looking back on my childhood, the amount of Nickelodeon and Cartoon Network I consumed was astounding. Sure, my days of cable networks and Spongebob watching are over but they’ve been replaced by a collection of streaming services just as willing to suck my productivity and further increase my already sedentary lifestyle.
That’s when I had the idea to put a system in place to both limit the amount of TV/streaming content I was consuming and encourage myself to meet my daily fitness goals of 10,000 steps or 600 active calories.
For me, I was already tracking my daily fitness metrics using a Garmin smart watch so the challenge was setting up some automation to block access to the streaming services I subscribe to on my home network… Challenge Accepted.
Requirements
- Pi-Hole DNS set up and running on your home network with your clients configured to use the Pi-Hole as its sole DNS Server
- Pi-Hole is how I’ve opted to block the various streaming services URLs (such as netflix.com)
- Pi-Hole is traditionally used to block ad services on clients in a home network but you can just as easily add URLs of your choosing to block :)
- A Garmin Watch configured to sync data to Garmin Connect (https://connect.garmin.com)
- Python or Docker installed on a machine in your home network that you keep running 24/7.
Setting up the Pi-Hole
There’s a million guides out there detailing how to set up a Pi-Hole so I’ll only highlight my unique setup.
Since I have an always-on Proxmox Server (using “Server” loosely here… Its just an old Dell Optiplex desktop I keep in the basement) setting up Pi-Hole as an LXC was stupid simple. In fact, all I did was use the script from https://tteck.github.io/Proxmox/#pi-hole-lxc and viola, I had Pi-Hole set up on my home network on 192.168.1.2
Of course, just having Pi-Hole running on your network isn’t enough. It’s best practice to configure your router to automatically distribute the Pi-Hole DNS IP address over DHCP so clients get the setting automatically when they connect. My Unifi Security Gateway and corresponding controller software provides me this option: By distributing the PiHole IP as the only DNS Server on my network, I ensure clients will be blocked when trying to reach the streaming service domains. Unfortunately, this also means if my PiHole is ever offline, clients won’t be able to connect to anything. High risk high reward!
Garmin Fitness Tracker
I’ve been using Garmin brand smart watches as my daily wear/fitness tracker for a number of years. I’m current rocking the Venu 3S (https://www.garmin.com/en-US/p/873214) and its proven to be a great product for tracking both active workout and daily healh metrics.
The watch stores all the fitness data locally on device and will periodically use a bluetooth connection to my iPhone to offload the data to garmin’s personalized cloud service called Garmin Connect. Logging in to connect.garmin.com
provides a nice looking UI with tons of data:
The UI and its graphs are pretty to look at but we only need two data points for this particular project:
- Current Active Calories Burned
- Current Steps
Remember, the goal is to write an automation that periodically grabs these two data sets and either blocks or unblocks a list of URLs assosicated with our streamining services.
So how to get this data programatically? Garmin doesn’t offer a Public API - That would be too simple! Luckily a super smart fellow that goes by Cyberjunky on Github has figured this out. They’ve provided a convienent Python library I can install with Pip
and with just a few lines of code, its possible to obtain all of my Garmin data!
Here’s a link to the python-garminconnect project on Github: https://github.com/cyberjunky/python-garminconnect
Writing Python Code To Pull Data from Garmin Connect
Example.py
import os
import json
from datetime import date
from garminconnect import (
Garmin,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
GarminConnectAuthenticationError,
)
from dotenv import load_dotenv
from garth.exc import GarthHTTPError
#initialize connection to garmin connect see https://github.com/cyberjunky/python-garminconnect
#for full example
def init_api(email, password, tokenstore):
"""Initialize Garmin API with your credentials."""
try:
# Using Oauth1 and OAuth2 token files from directory
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"
)
try:
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"
)
except (FileNotFoundError, GarthHTTPError, GarminConnectAuthenticationError, requests.exceptions.HTTPError) as err:
return None
return garmin
#Load environment variables containing creds
load_dotenv()
g_email = os.getenv("GARMIN_EMAIL")
g_password = os.getenv("GARMIN_PW")
tokenstore = os.getenv("GARMIN_CRED_PATH")
#Create connection to garmin using credentials
garmin_client = init_api(g_email,g_password,tokenstore)
#Get date to pass to garmin
today = date.today()
#Query Garmin API to get today's tats
stats = garmin_client.get_stats(today.isoformat())
#Obtain just the total steps and active calories
steps = stats['totalSteps']
activeCalories = stats['activeKilocalories']
print(f"Steps: {steps}")
print(f"Active Calories: {activeCalories}")
Output:
Trying to login to Garmin Connect using token data from directory './.garminconnect'...
Steps: 14287
Active Calories: 874.0
Excellent! I was able to pull my steps and active calories! Yes… this was a particularly actice day and it certainly is not my norm lol!
Now I need to set a daily goal. How many steps or active calories should I get in a day before I’m allowed to stream? For me 10,000 steps or 600 active calories seemed like a good fit.
I chose either steps or active calories because there are some days where I do a long bike ride and thus don’t get enough steps
Next I’ll need some more code to determine if I’ve met my goal:
if steps < 1000 and activeCalories < 600:
print("Goal not met, block streaming URLS in PiHole!")
This lead me to my next dillema… How do I programatically block and unblock the URLs on the Pi-Hole?
Pi-Hole API
I originally thought this would be a simple endeavor. The Pi-Hole is such a popular open source project that surely it had a nicely documented REST API. It turns out the answer was sort of… There is definitely an API but it appears to be completely undocumented. Atleast, I couldn’t find any documentation.
After reading various reddit threads and Github issue trackers I realized my best bet was going right to the PiHole source code: https://github.com/pi-hole/web/blob/master/api.php
After some reverse engineering, I did find a way to use the Python requests
library to add and remove URLs from my Pi-Hole blocklist via HTTP requests. Hooray!
At some point I’ll probably make a blog post specifically about the Pi-Hole API since a solid guide appears to be absent at this time.
if steps < 1000 and activeCalories < 600:
print("Goal not met, block streaming URLS in PiHole!")
pihole_url = "http://pi.hole/admin/api.php"
params = {
"list" : "black",
"add" : "netflix.com",
"auth" : "PIHOLE API TOKEN HERE"
}
res = requests.get(url=pihole_url,params=params)
print(res.json())
else:
print("Nice job meeting your fitness goal today, enjoy your streaming!")
pihole_url = "http://pi.hole/admin/api.php"
params = {
"list" : "black",
"sub" : "netflix.com",
"auth" : "PIHOLE API TOKEN HERE"
}
res = requests.get(url=pihole_url,params=params)
print(res.json())
The above is an oversimplified example but the gist is that you make a GET requeest to the /admin/api.php
endpoint and then pass in various URL parameters to either block (add) or allow (sub) the domains on your Pi-Hole DNS.
I found it particularly interesting that the authorization token obtained from PiHole is also passed in as a URL parameter… Nearly every other API I’ve worked with has you specify that as an authorization header.
There’s a lot more detail on how this part works in this projects README so I’ll move on for the purposes of this blog post.
Finishing Up
Sweet, all the pieces are in places, now just to tie them together so I meet the following requirements:
- The script should utilize a
.env
file that contains variables worth parameterizing such as the garmin account credentials, daily fitness goals, and how frequently to check if the fitness goals were met - The URLS to allow/block should be in an external file called blocklist.txt
- The project should be dockerized for easy deployment
My final project directory ended up looking like this:
.
├── data/
│ └── blocklist.txt
├── .env
├── gitfit.py
├── README.md
├── DOCKERFILE
├── docker-compose.yml
└── requirements.txt
gitfit.py
reads in the contents of .env
and blocklist.txt
to make a connection to Garmin Connect every 60 minutes to determine if I’ve met my fitness goals for the day. If I’ve met my fitness goals, then the script will iterate through the URLs in blocklist.txt
and allow them on my Pi-Hole. Otherwise, the script will do the same but block the URLs
As of today, here is my final version of gitfit.py
#gitfit.py
import os
import json
import requests
from garminconnect import (
Garmin,
GarminConnectConnectionError,
GarminConnectTooManyRequestsError,
GarminConnectAuthenticationError,
)
from dotenv import load_dotenv
from datetime import date,datetime
from pathlib import Path
from garth.exc import GarthHTTPError
import time
def init_api(email, password, tokenstore):
"""Initialize Garmin API with your credentials."""
try:
# Using Oauth1 and OAuth2 token files from directory
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"
)
try:
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"
)
except (FileNotFoundError, GarthHTTPError, GarminConnectAuthenticationError, requests.exceptions.HTTPError) as err:
return None
return garmin
def get_garmin_data(client):
today = date.today()
"""
Get activity data
"""
print("client.get_stats(%s)", today.isoformat())
print("----------------------------------------------------------------------------------------")
try:
stats = client.get_stats(today.isoformat())
with open("./data/current_stats.json", "w") as json_file:
json.dump(stats,json_file,indent=4)
return stats
except (
GarminConnectConnectionError,
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
) as err:
print("Error occurred during Garmin Connect Client get stats: %s" % err)
quit()
except Exception as e: # pylint: disable=broad-except
print(f"Unknown error occurred during Garmin Connect Client get stats: {e}")
quit()
def update_pihole(action: str, domain_block_list: list):
pihole_hostname = os.getenv("PIHOLE_HOST")
pihole_api_token = os.getenv("PIHOLE_API_TOKEN")
pi_url = f"http://{pihole_hostname}/admin/api.php"
print(f"Targeting Pi-Hole API using {pi_url}")
for domain in domain_block_list:
if domain[1] == 'exact':
list_type = "black"
elif domain[1] == "regex":
list_type = "regex_black"
else:
raise ValueError("Unexpected value passed to update_pihole function")
params = {
"list" : list_type,
action : domain[0],
"auth" : pihole_api_token
}
res = requests.get(url=pi_url,params=params)
if res.status_code == 200:
if res.json().get("success") == True:
if action == "add":
print(f"Successfully added {domain[0]} to Pi-Hole blocklist")
elif action == "sub":
print(f"Successfully removed {domain[0]} from Pi-Hole blocklist")
else:
print("Unexpected action passed to update_pihole function")
else:
print(f"Successfully communicated with Pi-Hole Server but did not get a success message back for domain: {domain}")
print(res.text)
else:
print(f"invalid response from Pi-Hole Server")
print(res.text)
def get_block_list(bl_path):
raw_list = [line for line in Path(bl_path).read_text().split("\n") if line]
final_list = []
for item in raw_list:
final_list.append(item.split(" "))
if len(final_list[-1]) != 2:
print(f"bad value: {final_list[-1]}")
raise ValueError("Each line in blocklist must have the domain and filter type separted by a single space")
if final_list[-1][1] != "exact" and final_list[-1][1] != "regex":
print(final_list[-1][1])
raise ValueError("The second item on each line of the blocklist must be either exact or regex")
print("Domains to Block/Allow:")
for domain in final_list:
print(domain)
return final_list
def main():
load_dotenv()
g_email = os.getenv("GARMIN_EMAIL")
g_password = os.getenv("GARMIN_PW")
step_goal = int(os.getenv("DAILY_STEP_GOAL"))
print(f"Daily Step Goal Set to: {step_goal} steps")
cal_goal = int(os.getenv("DAILY_CALORIE_GOAL"))
print(f"Daily Active Calorie Goal Set to: {cal_goal} calories")
tokenstore = os.getenv("GARMIN_CRED_PATH")
polling_freq = int(os.getenv("POLLING_FREQ"))
domain_block_list = get_block_list("./data/blocklist.txt")
garmin_client = init_api(g_email,g_password,tokenstore)
while True:
todays_stats = get_garmin_data(garmin_client)
curr_active_cals = todays_stats.get("activeKilocalories",0)
curr_steps = todays_stats.get("totalSteps",0)
if not curr_active_cals:
curr_active_cals = 0
if not curr_steps:
curr_steps = 0
current_time = datetime.now()
formatted_datetime = current_time.strftime("%Y-%m-%dT%H:%M:%S")
print(f"Current Timestamp: {formatted_datetime}")
print(f"Current Active Calories: {curr_active_cals}")
print(f"Current Steps: {curr_steps}")
if (curr_active_cals < cal_goal) and (curr_steps < step_goal):
#Daily fitness goals are not yet met, add domains to blocklist
update_pihole(action="add", domain_block_list=domain_block_list)
else:
#Daily fitness goals have been met, remove domains from pi-hole blocklist
update_pihole(action="sub", domain_block_list=domain_block_list)
print(f"sleeping for {polling_freq} seconds...")
time.sleep(polling_freq)
if __name__ == "__main__":
main()
and here is my blocklist.txt file:
#.\data\blocklist.txt
tv.apple.com exact
(www\.)?netflix\.com regex
(www\.)?max\.com regex
(www\.)?peacocktv\.com regex
(www\.)?paramountplus\.com regex
After running docker-compose up
I can see that the script is working and has successfully blocked the domains in blocklist.txt
:
----------------------------------------------------------------------------------------
Current Timestamp: 2024-06-17T16:10:21
Current Active Calories: 12.0
Current Steps: 418
Targeting Pi-Hole API using http://pi.hole/admin/api.php
Successfully added tv.apple.com to Pi-Hole blocklist
Successfully added (www\.)?netflix\.com to Pi-Hole blocklist
Successfully added (www\.)?max\.com to Pi-Hole blocklist
Successfully added (www\.)?peacocktv\.com to Pi-Hole blocklist
Successfully added (www\.)?paramountplus\.com to Pi-Hole blocklist
sleeping for 600 seconds...
And just to test it… I tried to go to https://netflix.com:
Github
This project is available on Github: https://github.com/JPSOAR/Gitfit-With-Garmin-Plus-PiHole
View the README on how to configure this to work with your own environment. Thanks for reading!