Compare commits

...

49 Commits

Author SHA1 Message Date
Ron
1029e21e23 Merge pull request #116 from alexives/add_body_composition_service
Add service for body composition
2024-01-02 12:33:56 +01:00
Ron
ac923b7517 Update services.yaml 2024-01-02 12:31:50 +01:00
Ron
cf5d39fb56 Update sensor.py 2024-01-02 12:31:16 +01:00
Ron
77dbbc9f47 Update manifest.json 2024-01-02 12:30:43 +01:00
Ron
2326be7455 Update const.py 2024-01-02 12:30:21 +01:00
Ron
bfb720ac5d Update __init__.py 2024-01-02 12:29:46 +01:00
Alex Ives
d20c9bedb2 Add service for body composition
Relates to https://github.com/cyberjunky/home-assistant-garmin_connect/issues/74
2023-12-30 15:42:19 -06:00
Ron
5e6f7ff6e1 Create FUNDING.yml 2023-12-26 20:26:50 +01:00
Ron
9d90c366d9 Merge pull request #113 from misa1515/patch-3
Update sk.json
2023-12-23 17:54:49 +01:00
Ron
891ddbce27 Bumped python-garminconnect version 2023-12-22 13:28:59 +01:00
misa1515
dc345c4d53 Update sk.json 2023-11-21 13:55:51 +01:00
Ron Klinkien
89222f0299 Small textual changes 2023-09-15 13:02:43 +02:00
Ron Klinkien
18304be84a Bumped python-garminconnect package to 0.2.3 to fix stability issues 2023-09-15 13:01:34 +02:00
Ron
ffd478cc60 Fixed typo 2023-07-12 13:23:13 +02:00
Ron
4e6b9310a1 Merge pull request #89 from jfparis/sleep_score
Pull sleepscore from Garmin interface
2023-07-12 12:58:23 +02:00
Jean-François Paris
e9fba7abbe Pull sleepscore from Garmin interface 2023-06-23 23:34:24 +01:00
Ron
17fa2bcc63 Update version 2023-05-22 10:17:21 +02:00
Ron
b26f36c41f Merge pull request #72 from ViPeR5000/patch-2
Update translation
2023-05-22 10:14:53 +02:00
Ron
845cce3bda Merge pull request #49 from ray0711/gear_sensor
Added gear sensors & gear service
2023-05-22 10:14:12 +02:00
Ron
15800b00d5 Merge pull request #80 from gorzelak/main
Adding last activities sensor
2023-05-22 10:11:49 +02:00
Ron
c9e9740137 Merge pull request #79 from pailloM/main
Remove pytz dependency in sensor.py
2023-05-22 10:10:23 +02:00
Krzysztof Gorzelak
b7f2a6e653 Adding last activities sensor 2023-05-05 10:09:46 +02:00
Paillomams
be150124b7 remove pytz dependency 2023-04-23 00:46:24 -04:00
Ron
28876a8709 Update manifest.json 2023-04-19 08:23:38 +02:00
ray0711
287a9a04c3 Merge branch 'main' of https://github.com/cyberjunky/home-assistant-garmin_connect into gear_sensor 2023-04-16 10:15:05 +02:00
Ron
de51403c43 Update manifest.json 2023-04-05 17:39:31 +02:00
Ron
36e5b7c6b8 Add support for China 2023-04-05 17:39:16 +02:00
Ron
9a6aea06c4 Delete .github/workflows directory 2023-03-28 12:18:31 +02:00
Ron
bc1ff88af2 Update hassfest.yml 2023-03-28 12:07:57 +02:00
Ron
48d738d1dc Update manifest.json 2023-03-28 11:44:38 +02:00
Rui Melo
d57b8e0223 update
fix word
2023-03-03 11:25:43 +00:00
Ron
0688f89be1 Fix manifest validation errors 2023-03-03 10:08:37 +01:00
Ron
985468d7a2 Update manifest.json 2023-03-03 09:52:56 +01:00
Ron
ff0ccb7e99 Update manifest.json 2023-03-03 09:52:34 +01:00
Ron
f59b41e7c4 Fixed errors for weight and stress sensors 2023-03-03 09:52:23 +01:00
Ron
0074b94723 Fixed syntax error 2023-02-03 09:14:27 +01:00
Ron
ee777314f9 Update manifest.json 2023-02-02 18:13:12 +01:00
Ron
58f5dd49db Merge pull request #62 from misa1515/patch-2
Create sk.json
2023-02-02 18:12:30 +01:00
Ron
7b66c4b7be Update manifest.json 2023-02-02 18:11:13 +01:00
Ron
1aa8cfb78f Use async_forward_entry_setups instead of async_setup_platforms 2023-02-02 18:10:58 +01:00
misa1515
a29be3127e Create sk.json
Created new translation to slovak language
2023-01-20 21:13:04 +01:00
Ron
ef91580157 Updated version 2023-01-10 09:05:17 +01:00
Ron
1c3e5a4385 Merge pull request #61 from arniebarni/main
Use local time zone for alarms
2023-01-10 08:33:02 +01:00
arniebarni
b6aebe1637 use local time zone for alarms 2023-01-09 19:47:11 +01:00
Raimund Huber
fa48f9f4e2 List default for actitivies on sensor, bugfixes 2022-12-08 20:10:58 +01:00
Raimund Huber
4f4d0b8425 Gear service to update default gear per activity on garmin connect 2022-12-06 22:02:51 +01:00
Raimund Huber
8760bc0a78 unreleased python-garminconnect version 2022-12-04 12:35:28 +01:00
Raimund Huber
8efd365163 Fixup exception for non-numeric sensor values e.g. stress_qualifier = UNKNOWN 2022-12-04 12:34:47 +01:00
Raimund Huber
cc6e0af4e5 add basic gear sensors 2022-12-04 12:03:10 +01:00
11 changed files with 577 additions and 69 deletions

13
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,13 @@
# These are supported funding model platforms
github: [cyberjunky] # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2]
patreon: # Replace with a single Patreon username
open_collective: # Replace with a single Open Collective username
ko_fi: # Replace with a single Ko-fi username
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
liberapay: # Replace with a single Liberapay username
issuehunt: # Replace with a single IssueHunt username
otechie: # Replace with a single Otechie username
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']

View File

@@ -1,15 +0,0 @@
name: Validate with hassfest
on:
push:
pull_request:
schedule:
- cron: "0 0 * * *"
jobs:
validate:
runs-on: "ubuntu-latest"
steps:
- uses: "actions/checkout@v2"
- uses: home-assistant/actions/hassfest@master

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2021 Ron Klinkien
Copyright (c) 2023 Ron Klinkien
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -3,7 +3,7 @@
# Garmin Connect
The Garmin Connect integration allows you to expose data from Garmin Connect to Home Assistant.
NOTE: This integration doesn't support 2FA on Garmin Connect, so if you have enabled it -and want to keep it- this integration doesn't work, it will try to login repeatedly and generate lots of 2FA codes via email.
NOTE: This integration doesn't support 2FA on Garmin Connect yet (support is coming), so if you have enabled it -and want to keep it- this integration doesn't work, it will try to login repeatedly and generate lots of 2FA codes via email.
The change of adding support for it is unlikely since the Garmin Connect API is closed source, and will not be open for open-sourced projects.
## Installation

View File

@@ -1,6 +1,9 @@
"""The Garmin Connect integration."""
from datetime import date
from datetime import timedelta
import logging
import asyncio
from collections.abc import Awaitable
from garminconnect import (
Garmin,
@@ -12,10 +15,16 @@ from garminconnect import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryNotReady, IntegrationError
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DATA_COORDINATOR, DEFAULT_UPDATE_INTERVAL, DOMAIN
from .const import (
DATA_COORDINATOR,
DEFAULT_UPDATE_INTERVAL,
DOMAIN,
GEAR,
SERVICE_SETTING,
)
_LOGGER = logging.getLogger(__name__)
@@ -33,11 +42,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = {
DATA_COORDINATOR: coordinator,
}
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
hass.config_entries.async_setup_platforms(entry, PLATFORMS)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -57,8 +64,13 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
"""Initialize the Garmin Connect hub."""
self.entry = entry
self.hass = hass
self.in_china = False
self._api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD])
country = self.hass.config.country
if country == "CN":
self.in_china = True
self._api = Garmin(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], self.in_china)
super().__init__(
hass, _LOGGER, name=DOMAIN, update_interval=DEFAULT_UPDATE_INTERVAL
@@ -89,17 +101,46 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> dict:
"""Fetch data from Garmin Connect."""
summary = {}
body = {}
activites = {}
alarms = {}
gear = {}
gear_stats = {}
gear_defaults = {}
activity_types = {}
sleep_data = {}
sleep_score = None
try:
summary = await self.hass.async_add_executor_job(
self._api.get_user_summary, date.today().isoformat()
)
_LOGGER.debug(summary)
_LOGGER.debug(f"Summary data: {summary}")
body = await self.hass.async_add_executor_job(
self._api.get_body_composition, date.today().isoformat()
)
_LOGGER.debug(body)
_LOGGER.debug(f"Body data: {body}")
activities = await self.hass.async_add_executor_job(
self._api.get_activities_by_date, (date.today()-timedelta(days=7)).isoformat(), (date.today()+timedelta(days=1)).isoformat()
)
_LOGGER.debug(f"Activities data: {activities}")
summary['lastActivities'] = activities
alarms = await self.hass.async_add_executor_job(self._api.get_device_alarms)
_LOGGER.debug(alarms)
_LOGGER.debug(f"Alarms data: {alarms}")
activity_types = await self.hass.async_add_executor_job(
self._api.get_activity_types
)
_LOGGER.debug(f"Activity types data: {activity_types}")
sleep_data = await self.hass.async_add_executor_job(
self._api.get_sleep_data, date.today().isoformat())
_LOGGER.debug(f"Sleep data: {sleep_data}")
except (
GarminConnectAuthenticationError,
GarminConnectTooManyRequestsError,
@@ -110,8 +151,109 @@ class GarminConnectDataUpdateCoordinator(DataUpdateCoordinator):
raise UpdateFailed(error) from error
return {}
try:
gear = await self.hass.async_add_executor_job(
self._api.get_gear, summary[GEAR.USERPROFILE_ID]
)
_LOGGER.debug(f"Gear data: {gear}")
tasks: list[Awaitable] = [
self.hass.async_add_executor_job(
self._api.get_gear_stats, gear_item[GEAR.UUID]
)
for gear_item in gear
]
gear_stats = await asyncio.gather(*tasks)
_LOGGER.debug(f"Gear stats data: {gear_stats}")
gear_defaults = await self.hass.async_add_executor_job(
self._api.get_gear_defaults, summary[GEAR.USERPROFILE_ID]
)
_LOGGER.debug(f"Gear defaults data: {gear_defaults}")
except:
_LOGGER.debug("Gear data is not available")
try:
sleep_score = sleep_data["dailySleepDTO"]["sleepScores"]["overall"]["value"]
_LOGGER.debug(f"Sleep score data: {sleep_score}")
except KeyError:
_LOGGER.debug("Sleep score data is not available")
return {
**summary,
**body["totalAverage"],
"nextAlarm": alarms,
"gear": gear,
"gear_stats": gear_stats,
"activity_types": activity_types,
"gear_defaults": gear_defaults,
"sleepScore": sleep_score,
}
async def set_active_gear(self, entity, service_data):
"""Update Garmin Gear settings"""
if not await self.async_login():
raise IntegrationError(
"Failed to login to Garmin Connect, unable to update"
)
setting = service_data.data["setting"]
activity_type_id = next(
filter(
lambda a: a[GEAR.TYPE_KEY] == service_data.data["activity_type"],
self.data["activity_types"],
)
)[GEAR.TYPE_ID]
if setting != SERVICE_SETTING.ONLY_THIS_AS_DEFAULT:
await self.hass.async_add_executor_job(
self._api.set_gear_default,
activity_type_id,
entity.uuid,
setting == SERVICE_SETTING.DEFAULT,
)
else:
old_default_state = await self.hass.async_add_executor_job(
self._api.get_gear_defaults, self.data[GEAR.USERPROFILE_ID]
)
to_deactivate = list(
filter(
lambda o: o[GEAR.ACTIVITY_TYPE_PK] == activity_type_id
and o[GEAR.UUID] != entity.uuid,
old_default_state,
)
)
for active_gear in to_deactivate:
await self.hass.async_add_executor_job(
self._api.set_gear_default,
activity_type_id,
active_gear[GEAR.UUID],
False,
)
await self.hass.async_add_executor_job(
self._api.set_gear_default, activity_type_id, entity.uuid, True
)
async def add_body_composition(self, entity, service_data):
"""Record a weigh in/body composition"""
if not await self.async_login():
raise IntegrationError(
"Failed to login to Garmin Connect, unable to update"
)
await self.hass.async_add_executor_job(
self._api.add_body_composition,
service_data.data.get("timestamp", None),
service_data.data.get("weight"),
service_data.data.get("percent_fat", None),
service_data.data.get("percent_hydration", None),
service_data.data.get("visceral_fat_mass", None),
service_data.data.get("bone_mass", None),
service_data.data.get("muscle_mass", None),
service_data.data.get("basal_met", None),
service_data.data.get("active_met", None),
service_data.data.get("physique_rating", None),
service_data.data.get("metabolic_age", None),
service_data.data.get("visceral_fat_rating", None),
service_data.data.get("bmi", None)
)

View File

@@ -1,12 +1,13 @@
"""Constants for the Garmin Connect integration."""
from datetime import timedelta
from enum import Enum
from typing import NamedTuple
from homeassistant.const import (
LENGTH_METERS,
MASS_KILOGRAMS,
UnitOfMass,
UnitOfTime,
UnitOfLength,
PERCENTAGE,
TIME_MINUTES,
TIME_YEARS,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -44,7 +45,7 @@ GARMIN_ENTITY_LIST = {
"netCalorieGoal": ["Net Calorie Goal", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
"totalDistanceMeters": [
"Total Distance Mtr",
LENGTH_METERS,
UnitOfLength.METERS,
"mdi:walk",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
@@ -69,7 +70,7 @@ GARMIN_ENTITY_LIST = {
"wellnessDescription": ["Wellness Description", "", "mdi:clock", None, SensorStateClass.TOTAL, False],
"wellnessDistanceMeters": [
"Wellness Distance Mtr",
LENGTH_METERS,
UnitOfLength.METERS,
"mdi:walk",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
@@ -86,18 +87,18 @@ GARMIN_ENTITY_LIST = {
"wellnessKilocalories": ["Wellness KiloCalories", "kcal", "mdi:food", None, SensorStateClass.TOTAL, False],
"highlyActiveSeconds": [
"Highly Active Time",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:fire",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
False,
],
"activeSeconds": ["Active Time", TIME_MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True],
"sedentarySeconds": ["Sedentary Time", TIME_MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
"sleepingSeconds": ["Sleeping Time", TIME_MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
"activeSeconds": ["Active Time", UnitOfTime.MINUTES, "mdi:fire", None, SensorStateClass.TOTAL, True],
"sedentarySeconds": ["Sedentary Time", UnitOfTime.MINUTES, "mdi:seat", None, SensorStateClass.TOTAL, True],
"sleepingSeconds": ["Sleeping Time", UnitOfTime.MINUTES, "mdi:sleep", None, SensorStateClass.TOTAL, True],
"measurableAwakeDuration": [
"Awake Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -105,7 +106,7 @@ GARMIN_ENTITY_LIST = {
],
"measurableAsleepDuration": [
"Sleep Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:sleep",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -113,7 +114,7 @@ GARMIN_ENTITY_LIST = {
],
"floorsAscendedInMeters": [
"Floors Ascended Mtr",
LENGTH_METERS,
UnitOfLength.METERS,
"mdi:stairs",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
@@ -121,7 +122,7 @@ GARMIN_ENTITY_LIST = {
],
"floorsDescendedInMeters": [
"Floors Descended Mtr",
LENGTH_METERS,
UnitOfLength.METERS,
"mdi:stairs",
SensorDeviceClass.DISTANCE,
SensorStateClass.TOTAL,
@@ -160,11 +161,11 @@ GARMIN_ENTITY_LIST = {
],
"averageStressLevel": ["Avg Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.TOTAL, True],
"maxStressLevel": ["Max Stress Level", "lvl", "mdi:flash-alert", None, SensorStateClass.TOTAL, True],
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
"stressDuration": ["Stress Duration", TIME_MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
"stressQualifier": ["Stress Qualifier", None, "mdi:flash-alert", None, None, False],
"stressDuration": ["Stress Duration", UnitOfTime.MINUTES, "mdi:flash-alert", None, SensorStateClass.TOTAL, False],
"restStressDuration": [
"Rest Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -172,7 +173,7 @@ GARMIN_ENTITY_LIST = {
],
"activityStressDuration": [
"Activity Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -180,7 +181,7 @@ GARMIN_ENTITY_LIST = {
],
"uncategorizedStressDuration": [
"Uncat. Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -188,7 +189,7 @@ GARMIN_ENTITY_LIST = {
],
"totalStressDuration": [
"Total Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -196,7 +197,7 @@ GARMIN_ENTITY_LIST = {
],
"lowStressDuration": [
"Low Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -204,7 +205,7 @@ GARMIN_ENTITY_LIST = {
],
"mediumStressDuration": [
"Medium Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -212,7 +213,7 @@ GARMIN_ENTITY_LIST = {
],
"highStressDuration": [
"High Stress Duration",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
None,
SensorStateClass.TOTAL,
@@ -276,7 +277,7 @@ GARMIN_ENTITY_LIST = {
],
"moderateIntensityMinutes": [
"Moderate Intensity",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:flash-alert",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -284,7 +285,7 @@ GARMIN_ENTITY_LIST = {
],
"vigorousIntensityMinutes": [
"Vigorous Intensity",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:run-fast",
SensorDeviceClass.DURATION,
SensorStateClass.TOTAL,
@@ -292,7 +293,7 @@ GARMIN_ENTITY_LIST = {
],
"intensityMinutesGoal": [
"Intensity Goal",
TIME_MINUTES,
UnitOfTime.MINUTES,
"mdi:run-fast",
None,
SensorStateClass.TOTAL,
@@ -389,14 +390,47 @@ GARMIN_ENTITY_LIST = {
None,
False,
],
"weight": ["Weight", MASS_KILOGRAMS, "mdi:weight-kilogram", SensorDeviceClass.WEIGHT, SensorStateClass.TOTAL, False],
"weight": ["Weight", UnitOfMass.KILOGRAMS, "mdi:weight-kilogram", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
"bmi": ["BMI", "bmi", "mdi:food", None, SensorStateClass.TOTAL, False],
"bodyFat": ["Body Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.TOTAL, False],
"bodyWater": ["Body Water", PERCENTAGE, "mdi:water-percent", None, SensorStateClass.TOTAL, False],
"boneMass": ["Bone Mass", MASS_KILOGRAMS, "mdi:bone", SensorDeviceClass.WEIGHT, SensorStateClass.TOTAL, False],
"muscleMass": ["Muscle Mass", MASS_KILOGRAMS, "mdi:dumbbell", SensorDeviceClass.WEIGHT, SensorStateClass.TOTAL, False],
"boneMass": ["Bone Mass", UnitOfMass.KILOGRAMS, "mdi:bone", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
"muscleMass": ["Muscle Mass", UnitOfMass.KILOGRAMS, "mdi:dumbbell", SensorDeviceClass.WEIGHT, SensorStateClass.MEASUREMENT, False],
"physiqueRating": ["Physique Rating", None, "mdi:numeric", None, SensorStateClass.TOTAL, False],
"visceralFat": ["Visceral Fat", PERCENTAGE, "mdi:food", None, SensorStateClass.TOTAL, False],
"metabolicAge": ["Metabolic Age", TIME_YEARS, "mdi:calendar-heart", None, SensorStateClass.TOTAL, False],
"metabolicAge": ["Metabolic Age", UnitOfTime.YEARS, "mdi:calendar-heart", None, SensorStateClass.TOTAL, False],
"nextAlarm": ["Next Alarm Time", None, "mdi:alarm", SensorDeviceClass.TIMESTAMP, None, True],
"lastActivities": ["Last Activities", None, "mdi:numeric", SensorStateClass.TOTAL, None, False],
"sleepScore": [
"Sleep Score",
None,
"mdi:sleep",
SensorStateClass.TOTAL,
SensorStateClass.MEASUREMENT,
True,
]
}
GEAR_ICONS = {
"Shoes": "mdi:shoe-sneaker",
"Bike": "mdi:bike",
"Other": "mdi:basketball",
"Golf Clubs": "mdi:golf",
}
class SERVICE_SETTING(NamedTuple):
"""Options for the service settings, see services.yaml"""
ONLY_THIS_AS_DEFAULT = "set this as default, unset others"
DEFAULT = "set as default"
UNSET_DEFAULT = "unset default"
class GEAR(NamedTuple):
UUID = "uuid"
TYPE_KEY = "typeKey"
TYPE_ID = "typeId"
USERPROFILE_ID = "userProfileId"
ACTIVITY_TYPE_PK = "activityTypePk"

View File

@@ -1,11 +1,11 @@
{
"domain": "garmin_connect",
"name": "Garmin Connect",
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
"requirements": ["garminconnect==0.1.50"],
"codeowners": ["@cyberjunky"],
"config_flow": true,
"documentation": "https://github.com/cyberjunky/home-assistant-garmin_connect",
"issue_tracker": "https://github.com/cyberjunky/home-assistant-garmin_connect/issues",
"iot_class": "cloud_polling",
"version": "0.2.8"
"requirements": ["garminconnect==0.2.12", "tzlocal"],
"version": "0.2.19"
}

View File

@@ -2,27 +2,40 @@
from __future__ import annotations
import logging
import voluptuous as vol
from numbers import Number
import datetime
import pytz
from tzlocal import get_localzone
from homeassistant.components.sensor import (
SensorEntity,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
UnitOfLength,
ATTR_ENTITY_ID,
CONF_ID,
)
from homeassistant.const import ATTR_ATTRIBUTION, CONF_ID
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.entity import DeviceInfo
from homeassistant.helpers.update_coordinator import (
CoordinatorEntity,
DataUpdateCoordinator,
)
from .alarm_util import calculate_next_active_alarms
from .const import (
DATA_COORDINATOR,
DOMAIN as GARMIN_DOMAIN,
GARMIN_ENTITY_LIST,
GEAR,
GEAR_ICONS,
)
_LOGGER = logging.getLogger(__name__)
@@ -66,9 +79,57 @@ async def async_setup_entry(
enabled_by_default,
)
)
if "gear" in coordinator.data:
for gear_item in coordinator.data["gear"]:
entities.append(
GarminConnectGearSensor(
coordinator,
unique_id,
gear_item[GEAR.UUID],
gear_item["gearTypeName"],
gear_item["displayName"],
None,
True,
)
)
async_add_entities(entities)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
"set_active_gear", ENTITY_SERVICE_SCHEMA, coordinator.set_active_gear
)
platform.async_register_entity_service(
"add_body_composition", BODY_COMPOSITION_SERVICE_SCHEMA, coordinator.add_body_composition
)
ENTITY_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): str,
vol.Required("activity_type"): str,
vol.Required("setting"): str,
}
)
BODY_COMPOSITION_SERVICE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): str,
vol.Optional("timestamp"): str,
vol.Required("weight"): float,
vol.Optional("percent_fat"): float,
vol.Optional("percent_hydration"): float,
vol.Optional("visceral_fat_mass"): float,
vol.Optional("bone_mass"): float,
vol.Optional("muscle_mass"): float,
vol.Optional("basal_met"): float,
vol.Optional("active_met"): float,
vol.Optional("physique_rating"): float,
vol.Optional("metabolic_age"): float,
vol.Optional("visceral_fat_rating"): float,
vol.Optional("bmi"): float
}
)
class GarminConnectSensor(CoordinatorEntity, SensorEntity):
"""Representation of a Garmin Connect Sensor."""
@@ -104,6 +165,10 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
@property
def native_value(self):
"""Return the state of the sensor."""
if self._type == "lastActivities":
return len(self.coordinator.data[self._type])
if not self.coordinator.data or not self.coordinator.data[self._type]:
return None
@@ -118,8 +183,8 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
)
if active_alarms:
date_time_obj = datetime.datetime.strptime(active_alarms[0], "%Y-%m-%dT%H:%M:%S")
timezone = pytz.timezone('UTC')
timezone_date_time_obj = timezone.localize(date_time_obj)
tz = get_localzone()
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
return timezone_date_time_obj
else:
return None
@@ -128,11 +193,11 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
if self._device_class == SensorDeviceClass.TIMESTAMP:
date_time_obj = datetime.datetime.strptime(value, "%Y-%m-%dT%H:%M:%S.%f")
timezone = pytz.timezone('UTC')
timezone_date_time_obj = timezone.localize(date_time_obj)
tz = get_localzone()
timezone_date_time_obj = date_time_obj.replace(tzinfo=tz)
return timezone_date_time_obj
return round(value, 2)
return round(value, 2) if isinstance(value, Number) else value
@property
def extra_state_attributes(self):
@@ -143,6 +208,10 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
attributes = {
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
}
if self._type == "lastActivities":
attributes["last_Activities"] = self.coordinator.data[self._type]
if self._type == "nextAlarm":
attributes["next_alarms"] = calculate_next_active_alarms(
self.coordinator.data[self._type]
@@ -172,3 +241,134 @@ class GarminConnectSensor(CoordinatorEntity, SensorEntity):
and self.coordinator.data
and self._type in self.coordinator.data
)
class GarminConnectGearSensor(CoordinatorEntity, SensorEntity):
"""Representation of a Garmin Connect Sensor."""
def __init__(
self,
coordinator,
unique_id,
uuid,
sensor_type,
name,
device_class: None,
enabled_default: bool = True,
):
"""Initialize a Garmin Connect sensor."""
super().__init__(coordinator)
self._unique_id = unique_id
self._type = sensor_type
self._uuid = uuid
self._device_class = device_class
self._enabled_default = enabled_default
self._attr_name = name
self._attr_device_class = self._device_class
self._attr_icon = GEAR_ICONS[sensor_type]
self._attr_native_unit_of_measurement = UnitOfLength.KILOMETERS
self._attr_unique_id = f"{self._unique_id}_{self._uuid}"
self._attr_state_class = SensorStateClass.TOTAL
self._attr_device_class = "garmin_gear"
@property
def uuid(self):
"""Return the entity uuid"""
return self._uuid
@property
def native_value(self):
"""Return the state of the sensor."""
if not self.coordinator.data or not self._stats():
return None
value = self._stats()["totalDistance"]
return round(value / 1000, 2)
@property
def extra_state_attributes(self):
"""Return attributes for sensor."""
gear = self._gear()
stats = self._stats()
gear_defaults = self._gear_defaults()
activity_types = self.coordinator.data["activity_types"]
default_for_activity = self._activity_names_for_gear_defaults(
gear_defaults, activity_types
)
if not self.coordinator.data or not gear or not stats:
return {}
attributes = {
"last_synced": self.coordinator.data["lastSyncTimestampGMT"],
"total_activities": stats["totalActivities"],
"create_date": stats["createDate"],
"update_date": stats["updateDate"],
"date_begin": gear["dateBegin"],
"date_end": gear["dateEnd"],
"gear_make_name": gear["gearMakeName"],
"gear_model_name": gear["gearModelName"],
"gear_status_name": gear["gearStatusName"],
"custom_make_model": gear["customMakeModel"],
"maximum_meters": gear["maximumMeters"],
}
attributes["default_for_activity"] = (
", ".join(default_for_activity) if default_for_activity else "None"
)
return attributes
def _activity_names_for_gear_defaults(self, gear_defaults, activity_types):
return list(
map(
lambda b: b["typeKey"],
filter(
lambda a: a["typeId"]
in map(lambda d: d["activityTypePk"], gear_defaults),
activity_types,
),
)
)
@property
def device_info(self) -> DeviceInfo:
"""Return device information."""
return {
"identifiers": {(GARMIN_DOMAIN, self._unique_id)},
"name": "Garmin Connect",
"manufacturer": "Garmin Connect",
}
@property
def entity_registry_enabled_default(self) -> bool:
"""Return if the entity should be enabled when first added to the entity registry."""
return self._enabled_default
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and self.coordinator.data and self._gear()
def _stats(self):
"""Get gear statistics from garmin"""
for gear_stats_item in self.coordinator.data["gear_stats"]:
if gear_stats_item[GEAR.UUID] == self._uuid:
return gear_stats_item
def _gear(self):
"""Get gear from garmin"""
for gear_item in self.coordinator.data["gear"]:
if gear_item[GEAR.UUID] == self._uuid:
return gear_item
def _gear_defaults(self):
"""Get gear defaults"""
return list(
filter(
lambda d: d[GEAR.UUID] == self.uuid and d["defaultGear"] is True,
self.coordinator.data["gear_defaults"],
)
)

View File

@@ -0,0 +1,112 @@
set_active_gear:
name: Set active gear for activity
fields:
activity_type:
required: true
name: activity type
description: garmin activity type
example: running
default: running
selector:
select:
options:
- running
- cycling
- hiking
- other
- walking
- swimming
setting:
required: true
name: setting
description: gear setting to apply
default: set this as default, unset others
selector:
select:
options:
- set this as default, unset others
- set as default
- unset default
entity_id:
description: entity
required: true
selector:
entity:
integration: garmin_connect
device_class: garmin_gear
add_body_composition:
name: Adds updated body composition metrics
fields:
weight:
required: true
name: Weight
description: Weight in KG
example: 82.3
timestamp:
required: false
name: Timestamp
description: Datetime string of when the measurements were recorded. Defaults to now.
example: 2023-12-30T07:34:00
bmi:
required: false
name: BMI (Body Mass Index)
description: Body mass index is based on weight and height.
example: 24.7
percent_fat:
required: false
name: Percent Fat
description: Percent body fat
example: 23.6
percent_hydration:
required: false
name: Percent Hydration
description: Percent body hydration
example: 51.2
visceral_fat_mass:
required: false
name: Visceral Fat Mass
description: Estimated mass of visceral fat in KG
example: 45.3
bone_mass:
required: false
name: Bone Mass
description: Estimated mass of bones in KG
example: 10.1
muscle_mass:
required: false
name: Muscle Mass
description: Estimated mass of muscle in KG
example: 15.2
basal_met:
required: false
name: Basel Metabolism
description: Basel metabolism
example: 1900
active_met:
required: false
name: Active Metabolism
description: Active metabolism
example: 840
physique_rating:
required: false
name: Physique Rating
description: Physique Rating
example: 28
metabolic_age:
required: false
name: Metabolic Age
description: Metabolic Age
example: 37
visceral_fat_rating:
required: false
name: Visceral Fat Rating
description: Visceral Fat Rating
example: 10
entity_id:
description: entity
required: true
selector:
entity:
integration: garmin_connect
device_class: weight

View File

@@ -6,7 +6,7 @@
"error": {
"cannot_connect": "Falha na liga\u00e7\u00e3o",
"invalid_auth": "Autentica\u00e7\u00e3o inv\u00e1lida",
"unknown": "Erro inesperado"
"unknown": "Erro desconhecido"
},
"step": {
"user": {
@@ -19,4 +19,4 @@
}
}
}
}
}

View File

@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "Účet je už nakonfigurovaný"
},
"error": {
"cannot_connect": "Nepodarilo sa pripojiť",
"invalid_auth": "Neplatné overenie",
"too_many_requests": "Príliš veľa žiadostí, skúste to neskôr.",
"unknown": "Neočakávaná chyba"
},
"step": {
"user": {
"data": {
"password": "Heslo",
"username": "Užívateľské meno"
},
"description": "Zadajte svoje poverenia.",
}
}
}
}