Garmin Replacement

My mission lately has been to replace commercial solutions that continually harvest your personal data with self-hosted alternatives that you control.

My wife and I use Strava and Garmin for fitness tracking and Withings for weight and blood pressure measurements. The convenience is pretty solid, but getting the data out of these services is a major hassle. Both Garmin and Strava ensure that any data you share with them becomes theirs to keep and use as they wish. One of my projects this weekend was to fix this little issue.

Both Strava and Garmin offer APIs, but there appears to be a lengthy request and approval process. Their forms are very long and require additional personal information. That ain’t happening for many reasons, but filling out forms isn’t something I have the patience for anyhow.

I then tried scraping their sites with 100% success in a browser, but they failed when flipped over to headless mode. They caught on and blocked my attempts with an impossible-to-bypass captcha. So forget you, Jobu. I’ll do it myself. Enter Python to the rescue.

When the garmin.py script below runs in the terminal, get_activity_data() will prompt you for your exercise details. If the activity involved distance, it’ll ask you additional questions. These values are then stored in an SQLite database using store_activity_data(). If you add in your Twitter API credentials, it’ll tweet out your success with funny pictures from activity_images().

Before you go and run this script for the first time, the database needs to be created. I’ve added an additional script at the very bottom of this post that needs to be run just the one time. Please note that if you copy this and add additional things to track, make sure to sync the field changes in both scripts.

garmin.py

import sqlite3
import tweepy
from datetime import datetime
import os

api_key = "API_KEY"
api_secret_key = "API_SECRET_KEY"
access_token = "ACCESS_TOKEN"
access_token_secret = "ACCESS_TOKEN_SECRET"

client = tweepy.Client(
    consumer_key=api_key,
    consumer_secret=api_secret_key,
    access_token=access_token,
    access_token_secret=access_token_secret
)

auth = tweepy.OAuth1UserHandler(api_key, api_secret_key, access_token, access_token_secret)
api = tweepy.API(auth)

activity_images = {
    "cycling": "cycling.jpg",
    "elliptical": "elliptical.jpg",
    "pilates": "pilates.jpg",
    "running": "running.jpg",
    "walking": "walking.jpg",
    "weights": "weights.jpg",
    "default": "default.jpg"
}

def get_activity_data():
    activity = input("Activity: ").strip()
    time = input("Time (e.g., 65 for 65 miinutes): ").strip()
    avg_hr = int(input("Avg HR: "))
    calories = int(input("Calories: "))

    distance = None
    pace = None

    if activity.lower() in ['cycling', 'jogging', 'running', 'walking']:
        distance = float(input("Distance (e.g., 5.2 for 5.2 km or miles): "))
        pace = float(input("Pace (e.g., 5.5 for 5:30 min/km or mile): "))

    return (activity, time, avg_hr, calories, distance, pace)

def store_activity_data(data):
    conn = sqlite3.connect('activity_data.db')
    cur = conn.cursor()

    timestamp = datetime.now().strftime('%Y-%m-%dT%H:%M:%SZ')
    data_with_timestamp = data + (timestamp,)

    cur.execute('''INSERT INTO activities (activity, time, avg_hr, calories, distance, pace, timestamp)
                   VALUES (?, ?, ?, ?, ?, ?)''', data_with_timestamp)

    conn.commit()
    conn.close()

def get_image_for_activity(activity):
    return activity_images.get(activity.lower(), activity_images['default'])

def tweet_activity(data):
    activity, time, avg_hr, calories, distance, pace = data

    activity_emojis = {
        "cycling": "🚴‍♂️",
        "elliptical": "🏋️‍♂️",
        "pilates": "🧘‍♀️",
        "running": "🏃‍♂️",
        "walking": "🚶‍♂️",
        "weights": "🏋️‍♂️"
    }

    emoji = activity_emojis.get(activity.lower(), "🔥")

    tweet = (
        f"{emoji} Just completed {activity} for {time}.\n"
        f"💓 Avg HR: {avg_hr}, 🔥 Calories burned: {calories}."
    )

    if distance and pace:
        tweet += f"\n🛣️ Distance: {distance} mi, 🕒 Pace: {pace} min/mi."

    tweet += f"\n\n#ActivityLog {datetime.now().strftime('%d %b')}"

    print("Generated tweet:", tweet)  # Debug

    image_path = get_image_for_activity(activity)
    print("Selected image path:", image_path)  # Debug

    try:
        if os.path.exists(image_path):
            print("Image found, attempting to upload.")  # Debug
            media = api.media_upload(image_path)
            client.create_tweet(text=tweet, media_ids=[media.media_id_string])
            print("Tweet with image posted successfully!")  # Debug
        else:
            print("Image not found, tweeting without image.")  # Debug
            client.create_tweet(text=tweet)
            print("Tweet without image posted successfully!")  # Debug

    except Exception as e:
        print("An error occurred while attempting to tweet:", e)  # Debug


if __name__ == "__main__":
    activity_data = get_activity_data()
    store_activity_data(activity_data)
    print("Activity data saved successfully!")

    tweet_choice = input("Would you like to tweet this activity? (y/n): ").strip().lower()
    if tweet_choice == 'y':
        tweet_activity(activity_data)

create_table.py

import sqlite3

conn = sqlite3.connect('activity_data.db')
cur = conn.cursor()

cur.execute('''CREATE TABLE IF NOT EXISTS activities (
                id INTEGER PRIMARY KEY AUTOINCREMENT,
                activity TEXT,
                time INTEGER,
                avg_hr INTEGER,
                calories INTEGER,
                distance REAL,
                pace REAL,
                timestamp TEXT
                )''')

conn.commit()
conn.close()

The database this script creates can be easily analyzed from Grafana. Below is my current, in-progress dashboard. Getting datetime to work with Grafana is a pain, but changing the formatting of the SQLite TEXT field with strftime('%s', strftime('%Y-%m-%d', timestamp)) as time, setting the time formatted column to time, and adding an override for the datetime field to local, seems to have addressed the SQLite storage deficiencies.

Grafana Dashboard

The hidden beauty of this solution is in its simplicity. You control what data you want to store and how to recall it. This database will be is currently visualized in Grafana, next to my stock portfolio. Now I can turn Wifi / Bluetooth on my watch off, and delete the Garmin app.

Discussion here: https://x.com/cmcwain/status/1824914120745025997

 

  • 28 August 2024
    • Stripped white space around text variable inputs. That nabbed me yesterday.
    • Fixed some grammar and punctuation on text in post.
  • 22 August 2024
    • Added Grafana dashboard screenshot and relevant description.
  • 18 August 2024
    • Added timestamp to entries (not sure why I didn't think of that earlier).
    • Changed time to integer, so it can be normalized as minutes.
Previous: Upcoming Projects Next: Custom Exercise Timer