Skip to content

Commit a650e20

Browse files
cyberjunkyclaude
andcommitted
Bump version to 3.0.5, update ha-garmin to 0.1.18, fix lactate threshold and vo2Max sensors
- Fix lactate threshold sensors: ha-garmin 0.1.18 flattens the response into a top-level dict with Garmin's "hearRate" typo; update value_fn and mock data accordingly; add preserve_value=True to both sensors - Simplify vo2Max value_fn to use pre-computed field from ha-garmin; add preserve_value=True - Update polyline card to show activity_name attribute in popup title; clear stub entity so users provide their own entity ID - Fix README: correct polyline sensor entity reference, document 3-file install requirement, add entity ID note, fix startTime template example - Prefer .venv Python in scripts/develop to avoid system Python mismatches Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 91e7821 commit a650e20

8 files changed

Lines changed: 90 additions & 26 deletions

File tree

CLAUDE.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# CLAUDE.md
2+
3+
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4+
5+
## Communication style
6+
7+
This user prefers caveman mode: terse, no filler, fragments OK, short synonyms. Drop articles/hedging/pleasantries. Technical terms exact. Code unchanged. Pattern: `[thing] [action] [reason]. [next step].`
8+
9+
## Commands
10+
11+
```bash
12+
scripts/setup # Install dependencies and pre-commit hooks
13+
scripts/test # Run pytest with coverage (pass extra args after --)
14+
scripts/lint # Run pre-commit + vulture dead-code check
15+
scripts/develop # Start local Home Assistant instance with the integration loaded
16+
```
17+
18+
Run a single test file:
19+
```bash
20+
pytest tests/test_sensor.py -v
21+
```
22+
23+
## Architecture
24+
25+
This is a Home Assistant custom integration that polls the Garmin Connect cloud API via the [`ha-garmin`](https://github.com/cyberjunky/ha-garmin) library. The library is the only Garmin API dependency — all data fetching happens there.
26+
27+
### Data flow
28+
29+
Each data domain has its own `DataUpdateCoordinator` subclass in [coordinator.py](custom_components/garmin_connect/coordinator.py). All coordinators share the same `GarminClient` and `GarminAuth` instances, and each calls one `client.fetch_*_data()` method per poll:
30+
31+
| Coordinator | Data |
32+
|---|---|
33+
| `CoreCoordinator` | Daily summary, steps, sleep, HR, stress, SpO2, body battery (~50 sensors) |
34+
| `ActivityCoordinator` | Last activity, last 10 activities, polyline, workouts (~5 sensors) |
35+
| `TrainingCoordinator` | Readiness, VO2max, HRV, training status, scores (~11 sensors) |
36+
| `BodyCoordinator` | Weight, BMI, hydration, fitness age, body composition (~17 sensors) |
37+
| `GoalsCoordinator` | Badges, points, active goals (~6 sensors) |
38+
| `GearCoordinator` | Gear stats (dynamic sensors per item), alarms |
39+
| `BloodPressureCoordinator` | Latest BP reading (~3 sensors) |
40+
| `MenstrualCoordinator` | Menstrual data (~9 sensors, disabled by default) |
41+
42+
### Sensor definitions
43+
44+
All sensors are declared as `GarminConnectSensorEntityDescription` tuples in [sensor.py](custom_components/garmin_connect/sensor.py), grouped by coordinator. Each description has:
45+
- `coordinator_type` — which coordinator feeds it
46+
- `value_fn` — lambda to extract the state value from coordinator data (falls back to `key` lookup if omitted)
47+
- `attributes_fn` — lambda to extract extra state attributes
48+
- `preserve_value=True` — retains last non-`None` value (used for weight, sleep, HRV which go `None` mid-day)
49+
50+
### Key data facts from `ha-garmin`
51+
52+
- `startTimeLocal` is **dropped** by the library; use `startTime` (UTC datetime) instead. In templates: `(a.startTime | as_datetime | as_local).strftime('%Y-%m-%d')`
53+
- `activityType` is simplified to a plain string (`"running"`, `"cycling"`, etc.)
54+
- `polyline` (GPS coordinates as `[{lat, lon}]`) lives on `lastActivityRoute` sensor, **not** `lastActivity`
55+
56+
### Custom Lovelace card
57+
58+
`www/garmin-polyline-card.js` renders activity routes using Leaflet. Users must copy **all three files** from `www/` to `<config>/www/`: the card JS plus `leaflet.js` and `leaflet.css`.
59+
60+
### Entity unique IDs
61+
62+
Format: `{entry_id}_{sensor_key}`. The v1→v2 migration in [\_\_init\_\_.py](custom_components/garmin_connect/__init__.py) rewrites unique IDs from the old `{email}_{key}` format and triggers reauth (tokens are incompatible between versions).

README.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,15 @@ Gear sensors are dynamically created for each piece of equipment registered in G
291291

292292
## Activity Route Map
293293

294-
The `Last Activity` sensor includes a `polyline` attribute with GPS coordinates when the activity has GPS data (`hasPolyline: true`). This can be displayed on a map using the included custom Lovelace card.
294+
The `Last Activity Route` sensor (`sensor.garmin_connect_last_activity_route`) contains a `polyline` attribute with GPS coordinates when the activity has GPS data. This can be displayed on a map using the included custom Lovelace card.
295295

296296
**Installation:**
297297

298-
1. Copy `www/garmin-polyline-card.js` to your `<config>/www/` folder
299-
2. Add as a resource: **Settings → Dashboards → ⋮ → Resources → Add Resource**
298+
1. Copy all three files from the `www/` folder to your `<config>/www/` folder:
299+
- `garmin-polyline-card.js`
300+
- `leaflet.js`
301+
- `leaflet.css`
302+
2. Add the card as a resource: **Settings → Dashboards → ⋮ → Resources → Add Resource**
300303
- URL: `/local/garmin-polyline-card.js`
301304
- Type: JavaScript Module
302305
3. Hard refresh your browser (Ctrl+Shift+R)
@@ -305,13 +308,15 @@ The `Last Activity` sensor includes a `polyline` attribute with GPS coordinates
305308

306309
```yaml
307310
type: custom:garmin-polyline-card
308-
entity: sensor.garmin_connect_last_activity
311+
entity: sensor.YOUR_PREFIX_last_activity_route
309312
attribute: polyline
310313
title: Last Activity Route
311314
height: 400px
312315
color: "#FF5722"
313316
```
314317
318+
> **Note:** The entity ID depends on your account name. Find yours in **Settings → Devices & Services → Garmin Connect** or search for `last_activity_route` in Developer Tools → States.
319+
315320
| Option | Default | Description |
316321
|--------|---------|-------------|
317322
| `entity` | (required) | Sensor entity with polyline attribute |
@@ -553,7 +558,7 @@ template:
553558
{% set today = now().strftime('%Y-%m-%d') %}
554559
{% set activities = state_attr('sensor.garmin_connect_last_activities', 'last_activities') | default([]) %}
555560
{% set running = namespace(total=0) %}
556-
{% for a in activities if a.activityType == 'running' and today in a.startTimeLocal %}
561+
{% for a in activities if a.activityType == 'running' and (a.startTime | as_datetime | as_local).strftime('%Y-%m-%d') == today %}
557562
{% set running.total = running.total + a.distance %}
558563
{% endfor %}
559564
{{ (running.total / 1000) | round(2) }}

custom_components/garmin_connect/manifest.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,6 @@
99
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
1010
"loggers": ["ha_garmin"],
1111
"quality_scale": "gold",
12-
"requirements": ["ha-garmin==0.1.15"],
13-
"version": "3.0.4"
12+
"requirements": ["ha-garmin==0.1.18"],
13+
"version": "3.0.5"
1414
}

custom_components/garmin_connect/sensor.py

Lines changed: 7 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -843,20 +843,19 @@ class GarminConnectSensorEntityDescription(SensorEntityDescription):
843843
translation_key="lactate_threshold_hr",
844844
coordinator_type=CoordinatorType.TRAINING,
845845
native_unit_of_measurement="bpm",
846-
value_fn=lambda data: (
847-
(data.get("lactateThreshold") or {}).get("speed_and_heart_rate") or {}
848-
).get("heartRate"),
846+
# Garmin API returns "hearRate" (typo), ha_garmin merges the list into a flat dict
847+
value_fn=lambda data: (data.get("lactateThreshold") or {}).get("hearRate"),
849848
attributes_fn=lambda data: data.get("lactateThreshold") or {},
849+
preserve_value=True,
850850
),
851851
GarminConnectSensorEntityDescription(
852852
key="lactateThresholdSpeed",
853853
translation_key="lactate_threshold_speed",
854854
coordinator_type=CoordinatorType.TRAINING,
855855
native_unit_of_measurement="m/s",
856-
value_fn=lambda data: (
857-
(data.get("lactateThreshold") or {}).get("speed_and_heart_rate") or {}
858-
).get("speed"),
856+
value_fn=lambda data: (data.get("lactateThreshold") or {}).get("speed"),
859857
attributes_fn=lambda data: data.get("lactateThreshold") or {},
858+
preserve_value=True,
860859
),
861860
# HRV — ha_garmin flattens hrvStatus from _get_hrv_data_raw via _add_computed_fields
862861
GarminConnectSensorEntityDescription(
@@ -907,13 +906,8 @@ class GarminConnectSensorEntityDescription(SensorEntityDescription):
907906
coordinator_type=CoordinatorType.TRAINING,
908907
state_class=SensorStateClass.MEASUREMENT,
909908
native_unit_of_measurement="mL/(kg·min)",
910-
value_fn=lambda data: data.get("vo2MaxValue")
911-
or (
912-
(
913-
((data.get("trainingStatus") or {}).get("mostRecentVO2Max") or {}).get("generic")
914-
or {}
915-
).get("vo2MaxValue")
916-
),
909+
value_fn=lambda data: data.get("vo2MaxValue"),
910+
preserve_value=True,
917911
),
918912
)
919913

requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
homeassistant>=2026.2.3
2-
ha-garmin==0.1.15
2+
ha-garmin==0.1.18
33
colorlog==6.10.1
44
setuptools==82.0.1

scripts/develop

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,11 @@ set -e
44

55
cd "$(dirname "$0")/.."
66

7-
# Determine the Home Assistant command to use
8-
if command -v hass &> /dev/null; then
7+
# Determine the Home Assistant command to use — prefer the venv to avoid
8+
# system Python version mismatches (e.g. pycares/aiodns incompatibility).
9+
if [ -f ".venv/bin/python3" ]; then
10+
HASS_CMD=".venv/bin/python3 -m homeassistant"
11+
elif command -v hass &> /dev/null; then
912
HASS_CMD="hass"
1013
else
1114
HASS_CMD="python3 -m homeassistant"

tests/conftest.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ def mock_training_data() -> dict:
192192
},
193193
"enduranceScore": {"overallScore": 45, "runningScore": 50},
194194
"hillScore": {"overallScore": 30, "cyclingScore": 25},
195-
"lactateThreshold": {"speed_and_heart_rate": {"heartRate": 162, "speed": 3.2}},
195+
"lactateThreshold": {"hearRate": 162, "speed": 3.2},
196196
"hrvStatusText": "Balanced",
197197
"hrvWeeklyAvg": 45,
198198
"hrvLastNightAvg": 42,

www/garmin-polyline-card.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,7 +101,7 @@ class GarminPolylineCard extends HTMLElement {
101101
}
102102

103103
_renderMap(coordinates, stateObj) {
104-
const activityName = stateObj.state || 'Activity';
104+
const activityName = stateObj.attributes.activity_name || stateObj.state || 'Activity';
105105

106106
if (this._map) {
107107
// Update existing map — guard against Leaflet not being ready
@@ -243,7 +243,7 @@ class GarminPolylineCard extends HTMLElement {
243243

244244
static getStubConfig() {
245245
return {
246-
entity: 'sensor.garmin_connect_last_activity',
246+
entity: '',
247247
attribute: 'polyline',
248248
title: 'Activity Route'
249249
};

0 commit comments

Comments
 (0)