Zum Inhalt springen
Alles Automatisch

Home AssistantDashboards & ThemesSnippets

Garmin Fitness Dashboard in Home Assistant: Aktivitäten, Ziele und Statistiken

Ich tracke meine sportlichen Aktivitäten seit einer Weile über eine Garmin-Uhr. Skating, Radfahren, Tennis. Irgendwann wollte ich die Daten nicht nur in der Garmin-App sehen, sondern direkt in Home Assistant. Also habe ich mir ein eigenes Fitness-Dashboard gebaut, das Monats- und Jahresstatistiken, Fortschrittsanzeigen und ein automatisches Wochen-Highlight anzeigt. Das Ganze läuft komplett über YAML, mit Input Helpern, Template-Sensoren und ein paar Automationen. Ich zeige dir Schritt für Schritt, wie du das bei dir einrichten kannst.

Was das Dashboard kann

Das Dashboard zeigt mir auf einen Blick, wo ich sportlich gerade stehe. Kilometer und Minuten pro Monat und pro Jahr, getrennt nach Sportart. Dazu ein Jahresfortschritt für mein Skating-Ziel, eine Hochrechnung ob ich das Ziel bis Jahresende schaffe, und das sportliche Highlight der Woche. Alles automatisch berechnet, ich muss da nichts manuell eintragen.

Die Daten kommen aus der Garmin-Integration und werden über Automationen in Input Helper geschrieben. Template-Sensoren rechnen daraus Fortschritte und Prognosen. Im Dashboard selbst stecken Mushroom Cards, Gauge Cards und ein Statistics Graph für den Skating-Verlauf der letzten 30 Tage.

Voraussetzungen

Damit das Setup funktioniert, brauchst du eine laufende Garmin-Anbindung in Home Assistant. Konkret brauchst du einen Sensor sensor.last_activities, der eine Liste der letzten Workouts als Attribut last_activities bereitstellt. Die offizielle Garmin Connect Integration liefert genau das.

Dazu brauchst du die Packages-Struktur in deiner configuration.yaml, damit du alles sauber in eine eigene Datei auslagern kannst:

yaml
homeassistant:
  packages: !include_dir_named packages/

An optionalen Frontend-Erweiterungen empfehle ich Mushroom Cards und card-mod aus HACS. Die sind nicht zwingend nötig, machen das Dashboard aber deutlich übersichtlicher.

Schritt 1: Input Helper anlegen

Die Basis bilden Input Helper, die Kilometer, Minuten und Einheiten pro Sportart speichern. Ich habe das nach Monat und Jahr getrennt, damit ich die Monatswerte am Ersten automatisch zurücksetzen kann, ohne die Jahreswerte zu verlieren.

Erstelle eine Datei packages/garmin.yaml mit folgendem Inhalt:

yaml
1input_text:
2  garmin_last_logged_activity_id:
3    name: Garmin – Letzte geloggte Aktivität
4    max: 20
5
6
7input_number:
8  # Tennis
9  garmin_tennis_month_km:
10    name: Garmin – Tennis Monat (km)
11    min: 0
12    max: 10000
13    step: 0.01
14    unit_of_measurement: "km"
15    mode: box
16
17  garmin_tennis_year_km:
18    name: Garmin – Tennis Jahr (km)
19    min: 0
20    max: 50000
21    step: 0.01
22    unit_of_measurement: "km"
23    mode: box
24
25  garmin_tennis_month_min:
26    name: Garmin – Tennis Monat (Min)
27    min: 0
28    max: 10000
29    step: 1
30    unit_of_measurement: "min"
31    mode: box
32
33  garmin_tennis_year_min:
34    name: Garmin – Tennis Jahr (Min)
35    min: 0
36    max: 50000
37    step: 1
38    unit_of_measurement: "min"
39    mode: box
40
41  # Radfahren
42  garmin_bike_month_km:
43    name: Garmin – Radfahren Monat (km)
44    min: 0
45    max: 10000
46    step: 0.01
47    unit_of_measurement: "km"
48    mode: box
49
50  garmin_bike_year_km:
51    name: Garmin – Radfahren Jahr (km)
52    min: 0
53    max: 50000
54    step: 0.01
55    unit_of_measurement: "km"
56    mode: box
57
58  garmin_bike_month_min:
59    name: Garmin – Radfahren Monat (Min)
60    min: 0
61    max: 10000
62    step: 1
63    unit_of_measurement: "min"
64    mode: box
65
66  garmin_bike_year_min:
67    name: Garmin – Radfahren Jahr (Min)
68    min: 0
69    max: 50000
70    step: 1
71    unit_of_measurement: "min"
72    mode: box
73
74  # Skaten
75  garmin_skate_month_km:
76    name: Garmin – Skating Monat (km)
77    min: 0
78    max: 10000
79    step: 0.01
80    unit_of_measurement: "km"
81    mode: box
82
83  garmin_skate_year_km:
84    name: Garmin – Skating Jahr (km)
85    min: 0
86    max: 50000
87    step: 0.01
88    unit_of_measurement: "km"
89    mode: box
90
91  garmin_skate_month_min:
92    name: Garmin – Skating Monat (Min)
93    min: 0
94    max: 10000
95    step: 1
96    unit_of_measurement: "min"
97    mode: box
98
99  garmin_skate_year_min:
100    name: Garmin – Skating Jahr (Min)
101    min: 0
102    max: 50000
103    step: 1
104    unit_of_measurement: "min"
105    mode: box
106
107  # Skating-Jahresziel in km (passe den initialen Wert in der UI an dein eigenes Ziel an)
108  garmin_skating_jahresziel:
109    name: Garmin – Skating Jahresziel
110    min: 0
111    max: 50000
112    step: 1
113    unit_of_measurement: "km"
114    mode: box
115
116counter:
117  garmin_tennis_sessions:
118    name: Garmin – Tennis-Einheiten
119    initial: 0
120    step: 1
121
122  garmin_bike_sessions:
123    name: Garmin – Radfahr-Einheiten
124    initial: 0
125    step: 1
126
127  garmin_skate_sessions:
128    name: Garmin – Skating-Einheiten
129    initial: 0
130    step: 1

Der input_text für garmin_last_logged_activity_id ist wichtig: Damit merkt sich die Automation später, welche Aktivität zuletzt verarbeitet wurde. So werden keine Einheiten doppelt gezählt.

Schritt 2: Template-Sensoren erstellen

Auf Basis der Input Helper kannst du jetzt Template-Sensoren bauen, die automatisch rechnen. Der Skating-Jahresfortschritt zum Beispiel zeigt dir in Prozent, wie nah du an deinem Jahresziel bist. Die Prognose rechnet hoch, ob du es bei gleichbleibendem Tempo bis Jahresende schaffst.

Füge diese Sensoren in die gleiche packages/garmin.yaml ein:

yaml
1template:
2  # Jahresfortschritt in Prozent
3  - sensor:
4      - name: "Garmin Skating Jahresfortschritt"
5        unique_id: garmin_skating_year_progress
6        unit_of_measurement: "%"
7        state_class: measurement
8        state: >
9          {% set km = states('input_number.garmin_skate_year_km') | float(0) %}
10          {% set ziel = states('input_number.garmin_skating_jahresziel') | float(0) %}
11          {% if ziel > 0 %}
12            {{ ((km / ziel) * 100) | round(1) }}
13          {% else %}0{% endif %}
14        attributes:
15          ziel_km: "{{ states('input_number.garmin_skating_jahresziel') | int(0) }}"
16        availability: >
17          {{ states('input_number.garmin_skate_year_km') not in ['unknown', 'unavailable'] }}
18
19  # Skating-Minuten Monat als Sensor mit device_class
20  - sensor:
21      - name: "Garmin Skating Minuten Monat"
22        unique_id: garmin_skating_min_month
23        unit_of_measurement: "min"
24        device_class: duration
25        state_class: measurement
26        state: >
27          {{ states('input_number.garmin_skate_month_min') | float(0) }}
28        availability: >
29          {{ states('input_number.garmin_skate_month_min') not in ['unknown', 'unavailable'] }}
30
31  # Skating-Projektion auf das Jahresende (kein hardcoded Jahr,
32  # der friendly_name wird automatisch zum aktuellen Jahr)
33  - sensor:
34      - name: "Skating-Projektion {{ now().year }}"
35        unique_id: garmin_skating_projection
36        unit_of_measurement: "km"
37        state_class: measurement
38        device_class: distance
39        state: >
40          {% set current_km = states('input_number.garmin_skate_year_km') | float(0) %}
41          {% set day = now().timetuple().tm_yday %}
42          {% if day > 0 %}
43            {{ (current_km / day * 365) | round(1) }}
44          {% else %}0{% endif %}
45        availability: >
46          {{ states('input_number.garmin_skate_year_km') not in ['unknown', 'unavailable'] }}
47
48  # Wochen-Highlight: längste Aktivität der letzten 7 Tage.
49  # Trigger-based, damit die Loop nur einmal läuft und nicht pro Attribut neu.
50  - trigger:
51      - platform: state
52        entity_id: sensor.last_activities
53        attribute: last_activities
54      - platform: time_pattern
55        hours: "/1"
56    sensor:
57      - name: "Garmin Wochen-Highlight"
58        unique_id: garmin_weekly_highlight
59        state: "{{ best.activityName if best else 'Keine Aktivität' }}"
60        attributes:
61          activityName: "{{ best.activityName if best else '–' }}"
62          distance: "{{ ((best.distance | float(0)) / 1000) | round(1) if best else 0 }}"
63          duration: "{{ ((best.duration | float(0)) / 60) | round(0) if best else 0 }}"
64          calories: "{{ best.calories | round(0) if best and best.calories is defined else 0 }}"
65          date: "{{ (best.startTime | string)[:10] if best and best.startTime is defined else '–' }}"
66        variables:
67          best: >
68            {% set acts = state_attr('sensor.last_activities', 'last_activities') | default([]) %}
69            {% set week_start = now() - timedelta(days=7) %}
70            {% set ns = namespace(best=none, best_dist=0) %}
71            {% for a in acts %}
72              {% set t = a.startTime | as_datetime(none) %}
73              {% if t and t >= week_start and (a.distance | float(0)) > ns.best_dist %}
74                {% set ns.best = a %}
75                {% set ns.best_dist = a.distance | float(0) %}
76              {% endif %}
77            {% endfor %}
78            {{ ns.best }}

Das Skating-Jahresziel setzt du nach dem Speichern in der Home-Assistant-UI über den Helper Garmin – Skating Jahresziel. Bei mir steht es auf der jeweiligen Jahreszahl, also 2026 km für 2026. Ob das klappt, sehe ich dann im Dezember. Der Friendly-Name vom Projektions-Sensor passt sich automatisch ans aktuelle Jahr an, da musst du nichts manuell umbenennen.

Schritt 3: Automationen für die Datenpflege

Damit die Input Helper automatisch gefüllt werden, brauchst du drei Automationen. Die erste setzt am Monatsanfang die Monatswerte zurück, die zweite einmal jährlich am 1. Januar die Jahreswerte und Counter, und die dritte erkennt neue Aktivitäten und schreibt Kilometer, Minuten und Einheiten in die passenden Helper.

Die Monatsreset-Automation:

yaml
1alias: Garmin – Monatswerte zurücksetzen
2mode: single
3triggers:
4  - trigger: time
5    at: "00:10:00"
6    alias: Täglicher Trigger um 00:10 Uhr
7  - trigger: time
8    at: "01:00:00"
9    alias: Fallback Trigger um 01:00 Uhr
10conditions:
11  - condition: template
12    alias: Nur ausführen wenn heute der 1. des Monats ist
13    value_template: "{{ now().day == 1 }}"
14actions:
15  - alias: Monatswerte (km & Minuten) auf 0 setzen
16    action: input_number.set_value
17    target:
18      entity_id:
19        - input_number.garmin_tennis_month_km
20        - input_number.garmin_tennis_month_min
21        - input_number.garmin_bike_month_km
22        - input_number.garmin_bike_month_min
23        - input_number.garmin_skate_month_km
24        - input_number.garmin_skate_month_min
25    data:
26      value: 0

Die Jahreswerte-Reset-Automation läuft analog, nur am 1. Januar und setzt zusätzlich die Counter zurück:

yaml
1alias: Garmin – Jahreswerte zurücksetzen
2mode: single
3triggers:
4  - trigger: time
5    at: "00:15:00"
6conditions:
7  - condition: template
8    value_template: "{{ now().month == 1 and now().day == 1 }}"
9actions:
10  - alias: Jahreswerte auf 0 setzen
11    action: input_number.set_value
12    target:
13      entity_id:
14        - input_number.garmin_tennis_year_km
15        - input_number.garmin_tennis_year_min
16        - input_number.garmin_bike_year_km
17        - input_number.garmin_bike_year_min
18        - input_number.garmin_skate_year_km
19        - input_number.garmin_skate_year_min
20    data:
21      value: 0
22  - alias: Counter auf 0 zurücksetzen
23    action: counter.reset
24    target:
25      entity_id:
26        - counter.garmin_tennis_sessions
27        - counter.garmin_bike_sessions
28        - counter.garmin_skate_sessions

Jetzt zur eigentlichen Aktivitäts-Automation. Sie hört auf Änderungen am Attribut last_activities von sensor.last_activities, schaut welche Aktivitäten neuer sind als die zuletzt geloggte ID, und akkumuliert je nach Sportart Kilometer, Minuten und Einheiten.

Drei Stolpersteine, die ich beim Bauen mehrfach getroffen habe und in der Version unten gleich mit gefixt sind:

  1. mode: queued statt single. Wenn last_activities sich kurz nacheinander mehrfach aktualisiert (passiert beim ersten Garmin-Sync nach einer Aktivität), würde mode: single die zweiten Trigger einfach verwerfen. Mit queued werden sie der Reihe nach abgearbeitet.
  2. last_id einmal vor der Schleife in eine Variable lesen. Wenn man die Variable in jeder Iteration neu aus dem Helper-State lädt, steht sie bei einem Mid-Sequence-Abbruch (z.B. Service-Call-Timeout) inkonsistent da, und Werte werden teils doppelt gezählt. Stattdessen einmal als start_last_id schnappen, am Ende einmal auf die höchste verarbeitete ID setzen.
  3. acts vor der Schleife sanitisieren. Die Garmin-Connect-Integration liefert startTime in einigen HA-Versionen als Python-datetime-Objekt. repeat.for_each mag aber nur reine Listen aus Strings/Zahlen und wirft sonst „Repeat for_each must be a list of items". Der Workaround ist, vor der Schleife eine neue Liste zu bauen, die nur die vier Felder hält, die wir wirklich brauchen, und alles in int/float/string zwingt.

Hier die komplette Automation:

yaml
1alias: Garmin – Neue Aktivität protokollieren
2mode: queued
3max: 10
4triggers:
5  - trigger: state
6    entity_id: sensor.last_activities
7    attribute: last_activities
8conditions:
9  - condition: template
10    alias: Mindestens eine neue Aktivität vorhanden
11    value_template: >-
12      {% set raw = state_attr('sensor.last_activities', 'last_activities') | default([]) %}
13      {% set last_id = states('input_text.garmin_last_logged_activity_id') | int(0) %}
14      {% if raw | length == 0 %}{{ false }}
15      {% else %}
16        {% set newest_id = raw | map(attribute='activityId') | map('int', 0) | max %}
17        {{ newest_id > last_id }}
18      {% endif %}
19variables:
20  start_last_id: "{{ states('input_text.garmin_last_logged_activity_id') | int(0) }}"
21  acts: >-
22    {% set raw = state_attr('sensor.last_activities', 'last_activities') | default([]) %}
23    {% set ns = namespace(items=[]) %}
24    {% for x in raw %}
25    {% set ns.items = ns.items + [{
26      'activityId': x.activityId | int(0),
27      'activityType': x.activityType | default(''),
28      'distance': x.distance | float(0),
29      'duration': x.duration | float(0)
30    }] %}
31    {% endfor %}
32    {{ ns.items }}
33actions:
34  - alias: Alle neuen Aktivitäten verarbeiten
35    repeat:
36      for_each: "{{ acts }}"
37      sequence:
38        - alias: Variablen für diese Aktivität setzen
39          variables:
40            act_id: "{{ repeat.item.activityId | int(0) }}"
41            typekey: "{{ repeat.item.activityType | default('') }}"
42            km: "{{ (repeat.item.distance | float(0)) / 1000 }}"
43            mins: "{{ ((repeat.item.duration | float(0)) / 60) | round(0) }}"
44        - alias: Nur verarbeiten wenn ID neuer als zu Beginn geloggte
45          condition: template
46          value_template: "{{ act_id > start_last_id }}"
47        - alias: Werte je nach Aktivitätstyp akkumulieren
48          choose:
49            - alias: Tennis
50              conditions:
51                - condition: template
52                  value_template: "{{ typekey == 'tennis_v2' }}"
53              sequence:
54                - action: input_number.set_value
55                  target: { entity_id: input_number.garmin_tennis_month_km }
56                  data:
57                    value: "{{ (states('input_number.garmin_tennis_month_km') | float(0) + km | float(0)) | round(2) }}"
58                - action: input_number.set_value
59                  target: { entity_id: input_number.garmin_tennis_year_km }
60                  data:
61                    value: "{{ (states('input_number.garmin_tennis_year_km') | float(0) + km | float(0)) | round(2) }}"
62                - action: input_number.set_value
63                  target: { entity_id: input_number.garmin_tennis_month_min }
64                  data:
65                    value: "{{ (states('input_number.garmin_tennis_month_min') | float(0) + mins | float(0)) | round(0) }}"
66                - action: input_number.set_value
67                  target: { entity_id: input_number.garmin_tennis_year_min }
68                  data:
69                    value: "{{ (states('input_number.garmin_tennis_year_min') | float(0) + mins | float(0)) | round(0) }}"
70                - action: counter.increment
71                  target: { entity_id: counter.garmin_tennis_sessions }
72
73            - alias: Radfahren
74              conditions:
75                - condition: or
76                  conditions:
77                    - condition: template
78                      value_template: "{{ typekey == 'cycling' }}"
79                    - condition: template
80                      value_template: "{{ typekey == 'e_bike_fitness' }}"
81              sequence:
82                - action: input_number.set_value
83                  target: { entity_id: input_number.garmin_bike_month_km }
84                  data:
85                    value: "{{ (states('input_number.garmin_bike_month_km') | float(0) + km | float(0)) | round(2) }}"
86                - action: input_number.set_value
87                  target: { entity_id: input_number.garmin_bike_year_km }
88                  data:
89                    value: "{{ (states('input_number.garmin_bike_year_km') | float(0) + km | float(0)) | round(2) }}"
90                - action: input_number.set_value
91                  target: { entity_id: input_number.garmin_bike_month_min }
92                  data:
93                    value: "{{ (states('input_number.garmin_bike_month_min') | float(0) + mins | float(0)) | round(0) }}"
94                - action: input_number.set_value
95                  target: { entity_id: input_number.garmin_bike_year_min }
96                  data:
97                    value: "{{ (states('input_number.garmin_bike_year_min') | float(0) + mins | float(0)) | round(0) }}"
98                - action: counter.increment
99                  target: { entity_id: counter.garmin_bike_sessions }
100
101            - alias: Skating
102              conditions:
103                - condition: template
104                  value_template: "{{ typekey == 'inline_skating' }}"
105              sequence:
106                - action: input_number.set_value
107                  target: { entity_id: input_number.garmin_skate_month_km }
108                  data:
109                    value: "{{ (states('input_number.garmin_skate_month_km') | float(0) + km | float(0)) | round(2) }}"
110                - action: input_number.set_value
111                  target: { entity_id: input_number.garmin_skate_year_km }
112                  data:
113                    value: "{{ (states('input_number.garmin_skate_year_km') | float(0) + km | float(0)) | round(2) }}"
114                - action: input_number.set_value
115                  target: { entity_id: input_number.garmin_skate_month_min }
116                  data:
117                    value: "{{ (states('input_number.garmin_skate_month_min') | float(0) + mins | float(0)) | round(0) }}"
118                - action: input_number.set_value
119                  target: { entity_id: input_number.garmin_skate_year_min }
120                  data:
121                    value: "{{ (states('input_number.garmin_skate_year_min') | float(0) + mins | float(0)) | round(0) }}"
122                - action: counter.increment
123                  target: { entity_id: counter.garmin_skate_sessions }
124  - alias: Letzte geloggte ID auf höchste verarbeitete ID setzen
125    action: input_text.set_value
126    target: { entity_id: input_text.garmin_last_logged_activity_id }
127    data:
128      value: "{{ acts | map(attribute='activityId') | map('int', 0) | max | string }}"

Damit ist sichergestellt: Jede Aktivität wird genau einmal gezählt, der Zustand bleibt auch bei Restarts und schnellen Folge-Triggern stabil, und die Schleife stolpert nicht mehr über datetime-Objekte. Wenn du eine weitere Sportart ergänzen willst, kopierst du einen der drei choose-Blöcke und passt typekey, die Helper-Namen und den Counter an.

Schritt 4: Dashboard anlegen

Für die Darstellung empfehle ich ein separates Dashboard. Geh dafür in Home Assistant auf Einstellungen > Dashboards > Dashboard hinzufügen und wechsle dann in den YAML-Modus.

Das Dashboard nutzt Sections mit maximal 3 Spalten. In der ersten Section siehst du Jahresziel und Fortschritt als Gauge, die Skating-Projektion und dein Fitness-Alter. In der zweiten Section stehen die letzten fünf Aktivitäten mit dynamischen Icons, je nach Sportart. Die dritte zeigt den Skating-Verlauf der letzten 30 Tage als Balkendiagramm. Danach kommen die Detailkarten für jede Sportart einzeln.

Die komplette Dashboard-YAML findest du in der Config-Sammlung. Kopiere den Inhalt, passe die Entity-IDs an dein Setup an und füge ihn im YAML-Modus ein.

Sportarten anpassen und erweitern

Das Setup ist bewusst so gebaut, dass du es einfach erweitern kannst. Wenn du statt Tennis zum Beispiel Laufen trackst, legst du dir die gleichen Input Helper an, mit garmin_run_month_km, garmin_run_year_km und so weiter. Die Automation erkennt den Aktivitätstyp über den Namen aus der Garmin API und ordnet die Werte entsprechend zu.

Bei mir laufen gerade drei Sportarten parallel. Das reicht für meinen Alltag. Du könntest aber ohne Probleme noch Schwimmen, Wandern oder was auch immer ergänzen. Das Muster ist immer das gleiche: Input Helper pro Sportart, Template-Sensoren für Berechnungen, Mushroom Cards im Dashboard.

Warum das Ganze in Home Assistant?

Garmin Connect hat natürlich selbst Statistiken. Aber ich finde es wahnsinnig praktisch, die Fitnessdaten zusammen mit allem anderen Smart-Home-Kram auf einem Dashboard zu haben. Morgens kurz aufs Tablet geschaut und ich sehe Wetter, Energieverbrauch und wie viele Kilometer ich diesen Monat schon geskatet bin. Alles an einem Ort.

Dazu kommt: Die Daten bleiben lokal. Keine Cloud-Abhängigkeit für die Auswertung. Und wenn Garmin irgendwann die App umstellt oder Features streicht, habe ich meine Zahlen trotzdem noch in Home Assistant.

Hast du auch Fitnessdaten in Home Assistant eingebunden? Schreib mir gerne in die Kommentare, was du damit trackst.

Diskussion im Forum

Video teilen

Community

Unterstütze den Kanal

Hat dir der Beitrag geholfen? Es gibt zwei Wege, etwas zurückzugeben.