Garmin Replacement

Updated: 16 February 2025

Goal

  • Own my fitness data
  • Use less things that track me
  • Personlize metrics that matter
  • Add some fun tweaks

My mission lately has been to replace commercial health-tracking products that continually harvest your personal data.

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 pain in the ass. Both Garmin and Strava ensure that any data you share with them becomes theirs forever.

Both Strava and Garmin offer APIs, but I didn’t have the patience to go through their process. I tried scraping their sites with much success but they eventually hit me with an impossible-to-bypass captcha. So forget you, Jobu. I’ll do it myself. Enter Python to the rescue!

With the following solution, we end up with a mobile-friendly dashboard that looks like this…

Calories

A data entry screen that looks like this…

Exercise

… and automatic tweets that look like this:

Kickboxing

Code

garmin.py

from flask import Flask, render_template, request, redirect, url_for
import sqlite3
from datetime import datetime
import tweepy
import os

app = Flask(__name__)
app.secret_key = 'supersecretkey'  # Needed for flash messages
app.config['TEMPLATES_AUTO_RELOAD'] = True

# Twitter API credentials
api_key = "asdf"
api_secret_key = "asdf"
access_token = "asdf"
access_token_secret = "asdf"
bearer_token = "asdf"

# Initialize tweepy.Client for v2 API and tweepy.API for v1.1 media upload
client = tweepy.Client(bearer_token=bearer_token,
                       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_v1 = tweepy.API(auth)

# Mapping activities to images
activity_images = {
    "cycling": "/garmin/cycling.jpg",
    "elliptical": "/garmin/elliptical.jpg",
    "pilates": "/garmin/pilates.jpg",
    "running": "/garmin/running.jpg",
    "jogging": "/garmin/running.jpg",
    "walking": "/garmin/walking.jpg",
    "weights": "/garmin/weights.jpg",
    "kickboxing": "/garmin/kickboxing.jpg",
    "default": "/garmin/default.jpg"
}

def get_db_connection():
    conn = sqlite3.connect('/garmin/activity_data.db')
    conn.row_factory = sqlite3.Row
    return conn

@app.route('/', methods=['GET', 'POST'])
def index():
    if request.method == 'POST':
        activity = request.form['activity']
        time = request.form['time']
        avg_hr = int(request.form['avg_hr'])
        calories = int(request.form['calories'])
        distance = float(request.form['distance']) if request.form['distance'] else None
        pace = float(request.form['pace']) if request.form['pace'] else None

        # Define the Los Angeles timezone
        la_tz = pytz.timezone('America/Los_Angeles')

        # Get the current time in Los Angeles time zone
        timestamp = datetime.now(la_tz).strftime('%Y-%m-%d %H:%M:%S')

        conn = get_db_connection()
        conn.execute('INSERT INTO activities (activity, time, avg_hr, calories, distance, pace, timestamp) VALUES (?, ?, ?, ?, ?, ?, ?)',
                     (activity, time, avg_hr, calories, distance, pace, timestamp))
        conn.commit()
        conn.close()

        if 'tweet' in request.form:
            tweet_activity((activity, time, avg_hr, calories, distance, pace))

        return redirect(url_for('index'))

    # Fetch recent activities for both GET and POST requests
    conn = get_db_connection()
    activities = conn.execute('SELECT activity, time, avg_hr, calories, distance, pace, date(timestamp) as date FROM activities ORDER BY timestamp DESC LIMIT 5').fetchall()
    conn.close()

    return render_template('index.html', activities=activities)

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": "🏋️‍♂️",
        "kickboxing": "🥊"
    }

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

    tweet = (
        f"{emoji} Just completed {activity} for {time} min.\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')}"

    image_path = get_image_for_activity(activity)

    try:
        if os.path.exists(image_path):
            media = api_v1.media_upload(image_path)
            client.create_tweet(text=tweet, media_ids=[media.media_id])
        else:
            client.create_tweet(text=tweet)
    except Exception as e:
        print(f"An error occurred while attempting to tweet: {e}")

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5001, debug=True)

 

index.html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>🏃 Activity Tracker 🏋️</title>
    <!-- Bootstrap CSS -->
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet">
    <style>
        body {
            padding: 20px;
            background-color: #f0f2f5;
            font-family: 'Arial', sans-serif;
        }
        h1 {
            color: #007bff;
            font-weight: bold;
        }
        .container {
            background-color: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.1);
        }
        .flash-message {
            padding: 10px;
            margin-bottom: 10px;
            border-radius: 4px;
        }
        .flash-message.error {
            background-color: #f8d7da;
            color: #721c24;
            border-color: #f5c6cb;
        }
        .flash-message.success {
            background-color: #d4edda;
            color: #155724;
            border-color: #c3e6cb;
        }
        form .btn-primary {
            background-color: #28a745;
            border-color: #28a745;
            transition: background-color 0.3s ease;
        }
        form .btn-primary:hover {
            background-color: #218838;
            border-color: #1e7e34;
        }
        .table {
            margin-top: 20px;
            border-radius: 8px;
            overflow: hidden;
        }
        .table th, .table td {
            text-align: center;
            vertical-align: middle;
        }
        .table thead {
            background-color: #007bff;
            color: white;
        }
        .table tbody tr:hover {
            background-color: #f1f1f1;
            transition: background-color 0.3s ease;
        }
    </style>
</head>

<body>
    <div class="container">
        <h1 class="my-4 text-center">Activity Tracker</h1>

        {% with messages = get_flashed_messages(with_categories=true) %}
            {% if messages %}
                {% for category, message in messages %}
                    <div class="flash-message {{ category }} alert alert-{{ 'success' if category == 'success' else 'danger' }}">
                        {{ message }}
                    </div>
                {% endfor %}
            {% endif %}
        {% endwith %}

        <!-- Form Section -->
        <form method="post" class="mb-4">
            <div class="mb-3">
                <label for="activity" class="form-label">Select Activity</label>
                <select name="activity" id="activity" class="form-select" required>
                    <option value="Elliptical">Elliptical</option>
                    <option value="Jogging">Jogging</option>
                    <option value="Kickboxing">Kickboxing</option>
                    <option value="Running">Running</option>
                    <option value="Walking">Walking</option>
                    <option value="Weights">Weights</option>
                </select>
            </div>
            <div class="mb-3">
                <input type="number" name="time" class="form-control" placeholder="Time (minutes)" required>
            </div>
            <div class="mb-3">
                <input type="number" name="avg_hr" class="form-control" placeholder="Average Heart Rate" required>
            </div>
            <div class="mb-3">
                <input type="number" name="calories" class="form-control" placeholder="Calories Burned" required>
            </div>
            <div class="mb-3">
                <input type="number" step="0.01" name="distance" class="form-control" placeholder="Distance (optional)">
            </div>
            <div class="mb-3">
                <input type="number" step="0.01" name="pace" class="form-control" placeholder="Pace (optional)">
            </div>
            <div class="form-check mb-3">
                <input type="checkbox" name="tweet" id="tweet" class="form-check-input">
                <label for="tweet" class="form-check-label">Tweet this activity</label>
            </div>
            <button type="submit" class="btn btn-primary">Log Activity</button>
        </form>

        <!-- Recent Activities Section -->
        <h2 class="my-4 text-center">Recent Activities</h2>
        {% if activities %}
            <table class="table table-striped table-bordered">
                <thead class="table-light">
                    <tr>
                        <th>Activity</th>
                        <th>Time</th>
                        <th>Avg HR</th>
                        <th>Calories</th>
                        <th>Distance</th>
                        <th>Pace</th>
                        <th>Date</th>
                    </tr>
                </thead>
                <tbody>
                    {% for activity in activities %}
                    <tr>
                        <td>{{ activity['activity'] }}</td>
                        <td>{{ activity['time'] }}</td>
                        <td>{{ activity['avg_hr'] }}</td>
                        <td>{{ activity['calories'] }}</td>
                        <td>{{ activity['distance'] if activity['distance'] else '' }}</td>
                        <td>{{ activity['pace'] if activity['pace'] else '' }}</td>
                        <td>{{ activity['date'] }}</td>
                    </tr>
                    {% endfor %}
                </tbody>
            </table>
        {% else %}
            <p class="text-center">No recent activities found.</p>
        {% endif %}
    </div>

    <!-- Bootstrap JS and dependencies (optional for responsiveness) -->
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

Summary

This python script uses Flask to serve a simple web page. When you enter exercise data, it updates your new SQLite activity_data.db. If you click the Tweet this activity button, it’ll tweet what you did for all your followers to see!

The SQLite database can be added to Grafana and customized for whatever you want. I use these dashboards throughout the day to track my overall health - which is critical due to my kidney diet requirements.

 

  • 16 February 2025
    • Added index.html for the Flask template.
  • 10 February 2025
    • Updated for current dashboard usage with Flask and Tweets.
  • 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